Add an extra llama-server arguments box to the model settings by danielhanchen · Pull Request #8702 · unslothai/unsloth · GitHub
Skip to content

Add an extra llama-server arguments box to the model settings - #8702

Merged
danielhanchen merged 72 commits into
mainfrom
flags-editor
Aug 14, 2026
Merged

Add an extra llama-server arguments box to the model settings#8702
danielhanchen merged 72 commits into
mainfrom
flags-editor

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Users keep asking for the full llama-server parameter surface. The README lists 283 distinct flags and Studio already emits or manages about 115 of them, so a control per flag is not viable and would permanently couple the panel to a CLI that changes every release. This adds a text box for the long tail, per model, at the bottom of Advanced settings.

The pass-through itself already existed end to end: LoadRequest.llama_extra_args, validate_extra_args, per-model persistence, and the CLI all use it. It just had no UI, which is why the overrides route deliberately preserves the field when the panel saves. With the box empty the emitted command is byte-identical to before.

Closing the denylist gap first

A flag that can be pasted from a forum post is not the same threat as one typed on a command line, so the audit came before the box.

--tools was already denied because it enables exec_shell_command. --agent is documented upstream as enabling CORS proxy and all built-in tools, so it reached the same capability one alias away. Now denied as well:

Flag Why
--agent, -ag, --no-agent, -no-ag the same tool set --tools was denied for
--tools-runtime takes docker:, podman: or ssh:<target>, so tools run on a remote host
--mcp-servers-config, --mcp-servers-json upstream says not to enable in untrusted environments
--cors-origins, --cors-credentials widen the child's CORS past Studio's boundary
--media-path serves local files
--log-file, --log-disable redirect the startup output Studio parses to classify a failed load
--slot-save-path Studio owns it for KV persistence across idle unload
--help, --usage, --version, --list-devices, --cache-list, --completion-bash exit instead of serving

--slots and --props stay the user's own call: Studio reads GET /props and never /slots.

Also added: a 256 token and 32 KiB bound with no empty tokens and no control characters, and a scrub of the LLAMA_ARG_* twins of every denied flag, since llama.cpp reads those before argv and denying the token alone left the capability reachable.

Validating against the installed binary

probe_server_capabilities already parsed llama-server --help into a flag to description map and threw it away, keeping only the supports_* booleans. That map is now returned from GET /api/inference/llama-flags, so what the editor checks is the binary you have, not a list shipped with Unsloth. A custom or newer llama.cpp is exactly the case a bundled list would get wrong.

What the row says:

  • a flag Unsloth manages is an error naming the control that owns it (--parallel is managed by Unsloth Studio and cannot be passed here), matching what validate_extra_args would refuse, and the Load button is blocked
  • a flag this build does not document is an amber warning and still loads, since the probe can be wrong about a custom build
  • a flag that shadows a control is a plain note about who wins, not a refusal: the backend appends extras last and reconciles its own sizing (parse_ctx_override and friends exist for that), and the CLI has always allowed it
  • a sampling flag is noted as a launch default, because sampling for a conversation comes from its chat settings
  • when the probe failed, nothing is called unknown at all, or an unverifiable build has every one of its flags marked a typo

On a failed start, _classify_llama_start_failure now names the flag instead of blaming the GGUF or memory. An unknown flag and a rejected value get different messages, since the fix differs, and stoi is translated into "the value is not a number".

What driving the real panel caught

Three things the unit tests could not see, all found by installing this branch and loading a model:

  1. The box came up empty for flags set outside the UI. The panel keys settings by repo:QUANT while the overrides route carries repo level flags into the first per-quant save, so it now reads both, into the text only, and an untouched config still omits the field so nothing is wiped.
  2. A refused flag was painted red with Load model still enabled, so the panel started a load the backend then rejected.
  3. --top-k 20 sat in the config and never reached llama-server. The panel's own Load goes through use-chat-model-runtime, which builds its payload field by field, and only shared-composer had been wired.

Testing

  • 808 passing across the nine affected backend suites, including new denylist coverage per alias, the bounds, the catalogue route with the probe working and failing, and command level tests that assert the argv
  • 2289 frontend tests, 52 of them new: a table driven tokeniser verified against Python's shlex.split(posix=True), round-tripping, and the diagnostics
  • live on a Studio installed from this branch: the box hydrates from flags set through the API, --parallel 8 blocks the load, --agent is refused, --tempp warns and still loads, and gemma-3-270m loaded with the command ending --jinja --spec-default --top-k 20
  • test_a_huge_unterminated_line_is_cheap from main caught the new regexes scanning a 10 MB unterminated line twice; the argument scan is now bounded to the tail, with a test

Not in this change

Promoting individual flags to their own controls (CPU threads, RoPE and YaRN, --override-kv, --cache-reuse, --numa) is a follow up. Sampling is deliberately not promoted: it is already per conversation in the chat settings, and a load time copy would duplicate and mislead.

llama-server documents 283 flags and Studio already emits or manages about
115 of them, so the long tail has no way to be set from the app. The
pass-through itself already existed end to end (LoadRequest.llama_extra_args,
validate_extra_args, per-model persistence, the CLI), it just had no control,
which is why the overrides route deliberately preserves the field when the
panel saves.

Adds the control, and closes the denylist gap that opens once a flag can be
pasted in rather than typed on a command line. --tools was already denied
because it enables exec_shell_command; --agent is documented as enabling all
built-in tools, so it reached the same capability one alias away. Also denied:
--tools-runtime (docker:, podman:, ssh:<target>), the MCP server flags, the
CORS flags, --media-path, the log redirection flags Studio parses its own
startup output through, --slot-save-path, and the flags that exit instead of
serving. --slots and --props stay a user choice: Studio reads GET /props and
never /slots.

The editor checks what is typed against the INSTALLED binary's --help rather
than a list shipped with Unsloth, since a custom or newer llama.cpp is exactly
the case a bundled list would get wrong. probe_server_capabilities already
parsed that map and threw it away; it is now returned by
GET /api/inference/llama-flags. A flag the build does not document warns but
still loads, an unprobeable build says nothing, and a flag Studio owns is
refused with the control that sets it named. A flag that shadows a control is
allowed and noted, because the backend appends extras last and reconciles its
own sizing, and the CLI has always allowed it.

With the box empty the emitted command is byte-identical to before.
…argument error

The panel keys per-model settings by repo:QUANT, but the overrides route carries
flags stored under the bare repo id into the first per-quant save, so reading
only the exact key showed an empty box for an entry that was about to be
inherited. Caught by driving the real panel: flags set through the API for
unsloth/gemma-3-270m-it-GGUF did not appear.

The unknown-argument message pointed every reader at the extra arguments box.
Nothing reaching the classifier says whose flag it was, and Unsloth emits its own
conditionally on the capability probe, so a binary swapped under a cached probe
lands there too and would be sent to a box it never came from.
The row painted a managed flag red and left Load model enabled, so the panel
started a load validate_extra_args then refused. Seen on a real Studio:
--parallel 8 showed its error and the button stayed live. The row now reports
loadability up, and the objection leaves with the row so a model without the
box (diffusion) still loads.
The Load button in the model settings goes through use-chat-model-runtime, which
builds its payload field by field, so the flags stayed in the config and never
reached llama-server. Caught by loading gemma-3-270m from the panel with
--top-k 20 in the box and reading the emitted command, which did not have it.

Omitted rather than nulled when the config has not read them, so the route still
preserves flags set outside this panel.
The forget path said flags have no UI control. They do now; what the sentence
was actually about is that an omitted field is preserved, which is what a save
that never opened the box relies on.
main's test_a_huge_unterminated_line_is_cheap caught this on the rebase: the two
new regexes ran over the whole capture, and _drain_stdout keeps an unterminated
line whole, so a 10 MB single line was scanned twice and the classifier went past
its 200 ms budget. llama.cpp prints the argument error and exits, so the tail is
where it always is.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b425f15dc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1232 to +1234
const [text, setText] = useState(() =>
formatExtraArgs(config.llamaExtraArgs),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the local argument text when resetting the config

When the user enters extra arguments and then clicks Reset, the parent replaces config with DEFAULT_PER_MODEL_CONFIG, but this local state retains its initial text because it never responds to subsequent config.llamaExtraArgs changes. The textarea therefore continues displaying the old arguments while handleRun sends the reset config without them, so the visible command and the command actually loaded disagree; synchronize this state for an explicit reset or clear it from the reset handler.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The row does respond to later config.llamaExtraArgs changes: the block below the catalogue effect compares the rendered stored text against the box and re-seeds when they diverge, so Reset (config becomes DEFAULT_PER_MODEL_CONFIG, tokens go from ["--top-k","20"] to none) clears the textarea. The one case it deliberately leaves standing is a box filled from a stored override the panel never owned: Reset does not clear the server's copy, the field stays omitted on save, and the load inherits it, so the box and the command still agree.

Comment on lines +1280 to +1284
// Sent only once known, and [] is the explicit "launch with none":
// the flags are llama-server's, so a transformers load never carries them.
...(isGguf && loadLlamaExtraArgs !== undefined
? { llama_extra_args: loadLlamaExtraArgs ?? [] }
: {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve launched arguments in the active runtime config

When a user loads from this panel with Remember disabled, these arguments are sent to llama-server but are never committed to the runtime store that useActiveModelConfig uses, while the non-remembered override is removed. Reopening settings for the still-running model therefore shows an empty box even though the active server is using the arguments, unlike the other click-time load settings recorded after success; retain the effective argument list in the active/loaded config as well as the request.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one. With Remember unchecked the user asked for nothing to be stored, and the load stays correct either way: the omission path inherits the resident process's own extras, so a Reload from that empty box relaunches with the same arguments rather than dropping them. Showing values from a config that was explicitly not remembered would also make Reset and the dirty state read against what is actually saved. The durable case, an override set through the API, is what the hydration in eea2da7 covers.

danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 13, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…list grew

Widening the denylist is the one part of this change that acts on data already on
disk. An override written when --log-file or --slot-save-path was still allowed
still holds it, and the two paths that CARRY such a value over rather than receive
it were refusing:

- the settings save re-validated the carried-over list, so changing Context Length
  on that model returned 400 naming a flag the user was not editing and cannot
  reach from that payload
- the load path caught the same refusal but degraded to no extras at all, so one
  name added to the denylist silently took every other flag with it

Both now use drop_managed_flags, which removes only the denied names (with their
values, or a bare /var/log/x would be read as a positional model path) and
re-validates what is left. An argument the caller just sent is still refused
loudly; an argument merely carried over from storage is dropped quietly.

Also off the event loop: the flag catalogue route awaited probe_server_capabilities
inline, and on a cold cache that runs llama-server --help with a 10s timeout. The
startup probes were moved to a thread for exactly this reason
(test_startup_llama_probe_non_blocking), so the first open of the panel after an
update would have stalled every other request.

Two new suites: the compatibility one covers every carry-over path with flags an
older build allowed, and the platform one runs the Cartesian product of
Linux/WSL/Windows/macOS with NVIDIA/multi-GPU/Vulkan/CPU-only, asserting the
command is unchanged with the box empty and that an extra arg never moves a
placement flag.

Twenty thousand random inputs through the box and through Python's shlex also agree
on every one that does not contain a backslash against a newline or end in a lone
backslash. Those two are deliberate: POSIX 2.2.1 makes an unquoted backslash before
a newline a line continuation, which shlex (a lexer, not a shell) does not
implement, and a text field that refuses a half-typed trailing escape is worse than
one that carries the character. Both are pinned as named tests now. The same run
showed an unquoted Windows path loses its separators, which is what every POSIX
shell does and not something to change in the splitter, so the hint asks for quotes
around backslashes rather than only around spaces.
Windows CI errored 16 tests in setup with ValueError: the environment variable is
longer than 32767 characters. pytest puts the whole parameter in the node id and
the node id into PYTEST_CURRENT_TEST, and these cases are 100 KB blobs, so the
limit is hit before the test body runs. Linux and macOS have no such cap, which is
why it went unnoticed. The blobs still exercise the linear-time claim; only their
ids are short now.
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 13, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…ow is hidden

Ten fixes from the review round, the two largest of which were the box telling the
truth about what will run:

- Hydration filled the textarea but left the config undefined, so clicking Load
  without editing sent no llama_extra_args at all. The route's omission path
  inherits from a resident same-model process, not from the stored override, so a
  cold start launched without the flags the box was showing. Confirmed against a
  live Studio: the override held --numa distribute and the emitted command did not.
  Hydration now writes the config too.
- Collapsing Advanced settings unmounts the row, and its cleanup withdrew the
  objection while the tokens stayed in the config, so Load was re-enabled for
  arguments the backend refuses. The panel now holds the flag and retires it on a
  model change instead.

The rest:

- StrictMode replays an effect (setup, cleanup, setup), and marking the key before
  the request left the first fetch cancelled and the second setup returning early,
  so hydration never ran in development. Marked once a response is in hand.
- The lookup checked two literal keys while the overrides route folds identities
  and falls back from repo:QUANT to the bare repo, so a model whose entry was
  written under another casing showed an empty box. resolveStoredExtraArgs mirrors
  that order.
- The editor checked the token cap but not the byte cap, so a grammar or JSON
  schema over 32 KiB was loadable in the UI and refused by validate_extra_args.
  Measured in UTF-8, which is what the backend counts.
- isDefaultConfig did not look at llamaExtraArgs, so a config whose only change was
  the box read as default and savePerModelConfig deleted the entry it was asked to
  remember.
- Records carrying the new field are stamped v3. This scheme exists so a client
  from before a field refuses to rewrite records containing it; without the stamp
  an older cached tab would normalize the arguments back out on its next save.
- The catalogue published llama.cpp's removal stubs (--draft-max and friends,
  listed only to say the argument has been removed), so the editor called a removed
  flag supported and stayed quiet about a load that then failed. _is_real already
  existed for this.
- probe_ok equated "parsed something" with "read the whole thing". A --help that
  exits nonzero after partial output leaves a non-empty catalogue, and every flag
  past the failure point would have been reported as unsupported. The probe's own
  result is published and used.
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 13, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eea2da7f75

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
seen.add(flag);

if (catalog?.managed.has(flag)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block managed flags while the catalog is loading

On a cold panel open, catalog remains null while /llama-flags performs its probe, which can take up to ten seconds; during that window this condition treats even denylisted inputs such as --model or --agent as valid and leaves Load enabled, although the backend will always return 400. The same mismatch persists if the catalog request fails, so managed-flag validation should be available independently of the slow capability probe or Load should remain blocked until it is known.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one. The window is the first panel open after an install or an update, the probe now runs off the event loop, and the catalogue is session-cached after that, so it is seconds once per session rather than per open. Blocking Load until it arrives would penalise every cold open to save a 400 that already names the flag, and the backend is the boundary here, not the row. The cases with no probe behind them (a managed flag with a control in this panel, an unclosed quote, the bounds, control characters, a non-numeric value) are all refused without the catalogue.

It only ever bounded the argument-error branches. The dyld branches above it have
to keep reading the whole capture, because the pathological part of the input they
guard against is the library name inside the line itself
(TestTheLibraryNameIsBounded), so a tail would cut off the framing they match on.
@danielhanchen

Copy link
Copy Markdown
Member Author

Cross-platform status, since the Windows job on the staging replica is red for both this branch and main:

test_a_huge_unterminated_line_is_cheap asserts the start-failure classifier stays under 200 ms on a 10 MB unterminated line. I staged upstream main alone on the same runner class to check whether this branch caused it:

that test other failures in the file
upstream main fails, 0.2335s 16 errors
this branch fails, 0.2027s none

The 16 errors are the PYTEST_CURRENT_TEST limit: pytest puts the whole parameter in the node id, three cases here are 100 KB blobs, and Windows caps an environment variable at 32767 characters. Those are fixed here by naming the cases.

The timing assertion is pre-existing and marginal. Locally the classifier costs 104.5 ms on main and 104.8 ms on this branch (best of 45 runs each), so the +0.3% is not what puts it over; roughly half the budget already goes on the macOS loader branches scanning the whole capture. Bounding those to a tail takes it to 29 ms, but it also breaks TestTheLibraryNameIsBounded and TestTheDyldReasonIsBounded, whose whole point is a dyld line where the library name is itself the pathological part, so the framing they match sits before any tail. That belongs in its own change rather than this one; the argument-error branches added here are bounded, which is why this branch measures faster than main.

ubuntu-latest, macos-14 and the Playwright job (chromium, firefox, webkit) are green.

danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 13, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

danielhanchen and others added 2 commits August 13, 2026 16:15
The auto-switch path was the one place a stored override still became an explicit
request: model_override_load_kwargs copies llama_extra_args straight into
LoadRequest, and an explicit list is refused rather than trimmed, so an override
written before a flag was denylisted broke every OpenAI auto-switch and idle reload
of that model until someone rewrote the entry by hand. It is sanitized at that seam
now, like the inheritance and settings-save paths.

The editor also told two small lies and enabled two loads that could not succeed:

- --device, -dev, --main-gpu and -mg are stripped from the command whenever the GPU
  picker owns placement (_strip_device_extra_args), so the "passed after the
  settings above, so yours wins" reading was wrong for them. They are now called
  removed, and only while a GPU is actually selected.
- --ctx-size nope and -ngl many reach parse_ctx_override and
  parse_gpu_layers_override, which refuse the load. The row mirrors those checks.
- A control character (the usual source being a command copied out of coloured
  terminal output) is refused by the backend and was loadable here.
- A GGUF that turns out to be DiffusionGemma after classification kept its tokens
  in the loadable config, and the diffusion shim records them without ever passing
  them to anything. Cleared with the other flags that path already drops.

And the trim that keeps a legacy list inside the new bounds no longer leaves a flag
without its value: ['--grammar', <33 KiB>] became ['--grammar'], which this
validator accepts (it knows the arity of only a few flags) and llama-server then
refuses at startup.

/validate now carries the arguments too. It already resolves and passes them to the
estimator; only the request field was missing, so a --ctx-size or cache override
sized the preflight differently from the load it was approving.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…load

Four things a closer read of the review turned up.

Hydration ran inside the extra-arguments row, which lives in Advanced
settings and is not rendered while that section is collapsed, so a panel
opened closed never fetched the stored override and a cold load launched
without it. Moved to the panel, which always mounts.

The startup auto-load built its request from the local config and left
the flags out. There is nothing resident at that point for the omission
path to inherit them from, so a remembered list was dropped on exactly
the load that most needs it. The preflight check sends them too, or it
would approve a command the load then refuses.

The flag catalogue was cached for the whole session. A llama.cpp update
or a backend switch replaces the binary in place, so a newly added flag
kept being reported as unknown. One minute now, plus an explicit drop on
both paths that change the binary.

Short aliases were parsed out of --help and thrown away, so typing -fa
warned that a valid flag was not in this build.
A backslash-newline contributes nothing, so it must not mark the start of
a token: a command wrapped and indented under its first line, which is
how they are pasted, gained an empty token at that indentation and sent
llama-server a bare positional it reads as a model path.

The numeric-value check ran once per flag name, but llama.cpp reads the
last occurrence and so does parse_gpu_layers_override, so `-ngl 20 -ngl
many` left Load enabled for a request that comes back 400. Checked at
every occurrence now, reported once.

drop_managed_flags put a rejected VALUE into the list its callers join
into a warning log. A stored value carrying ANSI escapes belongs in no
log line, so it is a placeholder now, and the flag it belonged to is
named rather than disappearing silently.
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 13, 2026
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 13, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: f93c66c154

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…ration

The env-twin sweep took LLAMA_ARG_MMPROJ and LLAMA_ARG_MMPROJ_URL with it.
--mmproj is refused in the box because Unsloth resolves the projector itself,
but the environment twin is an INPUT: _launch_has_mmproj reads both to know the
launch has a projector at all, and that is what keeps the vision and audio state
of a model loaded through an inherited one. Scrubbing them cleared that state,
which test_gpu_init_crash_message caught on three cases. Only the paravirtual
CPU recovery drops them now, as before, where an unpinned projector is the
corrupt path it is undoing. The pooling twins stay listed but noted: load_model
already pops LLAMA_ARG_POOLING / _RERANKING / _EMBEDDINGS itself.

The auto-load failure-gate harness slices chat-adapter.ts and evaluates it, so
the stored-arguments hydration added there left three names undefined and every
scenario failed on the harness's own missing-symbol assertion. Stubbed neutrally:
these scenarios are about the failure gate, not about which flags a model
launches with, so fetchLoadExtraArgs answers 'nothing stored' and the sanitizer
is identity, leaving the /load payload each scenario asserts on unchanged.

Merged origin/main first, so this branch carries #8783 (a deliberate segfault in
the probe tests no longer dumps core).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12a12d35fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

len(effective_extra_args),
model_log_label,
)
effective_extra_args = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match diffusion validation to manual -ngl translation

When a DiffusionGemma request uses Manual GPU Memory and supplies -ngl in llama_extra_args, /load translates that flag into request.gpu_layers before dropping the extra arguments, but /validate drops the list here without performing that translation. For example, gpu_layers: 0 plus -ngl 20 is validated as a zero-VRAM load during active training, then actually launches 20 GPU layers; the inverse combination can incorrectly return 409 for a CPU load. This also affects the UI when diffusion classification was inconclusive while the extra-arguments row was staged, so validation must either apply the same translation or both paths must consistently ignore the flag.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it is the sharper half of the asymmetry: /validate is the call that approves the switch, so it has to judge the command /load will actually run.

/load translates the last-wins -ngl out of the extras into request.gpu_layers when Manual GPU Memory owns the offload flags, then strips them, and the training guard reads _diffusion_manual_ngl(gpu_memory_mode, gpu_layers). /validate skipped both, so with gpu_layers: 0 and ["-ngl", "20"] the guard saw a zero-layer diffusion split, returned before the VRAM check, and the launch then put twenty layers on the GPU during training. The inverse pairing (gpu_layers: 20, -ngl 0) could 409 a load that never leaves the CPU.

It now runs the same translation and the same strip_shadowing_flags(..., strip_offload = True) under the same manual-only condition, before placement, so the guard receives the same layer count and the same list either way. Auto mode is untouched: there the flag is a pass-through the loader honours, and translating it here would invent a first-class value /load never set.

Three tests in test_validate_diffusion_extra_args.py: -ngl 20 over gpu_layers 0 reaches the guard as 20 with the flag stripped, -ngl 0 over gpu_layers 20 reaches it as 0, and an Auto-mode request keeps both the field and the flag as they were.

const body = (await res.json()) as ApiLlamaFlagCatalog;
// An older backend answers the route without the parameter and returns its
// full catalogue, which carries the same list.
cachedManaged = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard managed-catalog responses across invalidation

If a managed-only request is in flight when a llama.cpp update or backend switch calls invalidateLlamaFlagCatalog, the old request can resume here and repopulate cachedManaged after it was cleared. Unlike the full-catalog request, this path has no generation check or TTL, and its unconditional finally can also clear a newer in-flight request, so the old backend's defaultParallelSlots may remain cached for the rest of the session and incorrectly allow or reject stored --batch-size values. Capture and verify the catalog generation before publishing this response, as the full-catalog path does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The full catalogue reads the generation before the request and checks it before publishing; this path had neither, and its finally was unconditional, so it could also clear a newer request in flight.

The reason it matters is the field I added: the denylist in this answer is Unsloth own and no binary changes it, but defaultParallelSlots travels beside it and that is the EFFECTIVE count, which depends on whether the probed binary supports --kv-unified. An in-flight managed request resolving after an update therefore put the old build slot floor back into the cache the invalidation had just cleared, where it would sit for the rest of the session, and the hidden hydration check would allow a stored --batch-size 2 on a build now serving four slots.

Same shape as the catalogue now: generation captured before the fetch, checked before cachedManaged is written, and the finally clears the in-flight promise only while it is still ours. Guarded by a source-level test alongside the existing catalogue one.

Comment on lines +891 to +893
// Before the obligation is replaced: "--numa --verbose" leaves --numa without
// the value it needs, and only this point in the walk still knows that.
noteOwed(pendingOwner, pendingValues);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve quoted leading-hyphen values as values

When a value-taking option is followed by an explicitly quoted value that begins with a hyphen, such as --chat-template '- hello', parseExtraArgs removes the quotes and extraArgFlagName classifies the resulting token as another flag. This call therefore reports the preceding option as missing its value and disables Load, even though the backend validator accepts the token list and forwards it verbatim. Preserve enough quote/token-position information to treat an explicitly quoted flag-shaped token as the pending option's value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and a false refusal, which is the worse direction. parseExtraArgs takes the quotes off, extraArgFlagName then reads - hello as flag-shaped, and the row reported --chat-template as missing its value and disabled Load, while validate_extra_args accepts the list and llama.cpp reads it correctly: for a value-taking option it takes the next argv element without looking at what it starts with.

The tokeniser now records which tokens the user quoted (argv has no room for that, so it stays in the editor) and the diagnostics consult it only in value POSITION. That distinction matters both ways:

  • --chat-template "- hello" and --grammar "-x" are values, no error, and the value is not reported as an unknown flag either
  • "--top-k" 20 is still a flag, because quoting out of habit is common and reading it as a value would refuse a list that runs
  • a quoted token with no option in front of it has nothing to be the value of, so it is judged as written: unknown to this build, warned about, still passed, which is the answer the backend gives

Six assertions added, including that the token list itself is unchanged and only the quote marks are new.

…typed

/load translates an explicit -ngl out of the extra arguments into the first-class
field when Manual GPU Memory owns the offload flags, then strips them. /validate
did not, so the call that APPROVES the switch judged a different command than the
one that runs: a diffusion GGUF asked with gpu_layers 0 and '-ngl 20' was approved
as a load that places nothing on any device and cannot compete with training for
VRAM, and then launched twenty layers on it; the opposite pairing refused a load
that only ever runs on the CPU. Same translation and same strip now, under the
same manual-only condition, so both paths size the same launch.

The managed flag answer is guarded by the catalogue generation, as the full
catalogue already was. Its denylist is Unsloth's own, but defaultParallelSlots
travels with it and that is the effective count for the probed binary, so a
request already on the wire when llama.cpp is replaced could repopulate the cache
the invalidation had just cleared and keep the old build's slot floor for the rest
of the session. Its finally is conditional too, or it would clear a newer
request's in-flight promise.

A quoted value beginning with a hyphen is a value again. parseExtraArgs drops the
quotes, so '--chat-template "- hello"' left a flag-shaped token in value
position and the row called the option's value missing, disabling Load over a
list the backend accepts and llama.cpp reads correctly. The tokeniser records
which tokens were quoted and the diagnostics read that only in value POSITION: a
flag quoted out of habit is still a flag, and a quoted token with no option in
front of it is judged as written.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

https://github.com/unslothai/unsloth/blob/7ddba98a1eccf80fa5ddf43083a727438660f34d/studio/backend/routes/inference.py#L7968
P2 Badge Ignore extra arguments before validating non-GGUF loads

When a Transformers or MLX model is loaded with a shared request payload containing llama_extra_args, this unconditional validation can return 400 for managed flags, bare values, or oversized arguments even though the new LoadRequest contract says the field is ignored for non-GGUF models. Such payloads were previously accepted because the unknown field was ignored, so clients that reuse one load shape across backend types can now fail before model classification; defer validation until config.is_gguf is known (and after diffusion is excluded), or remove the ignored-field guarantee.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// "this config never read them", and the route preserves the stored
// flags when the field is omitted. Falling back to another model's
// value, or to null, would clear flags the user set elsewhere.
const loadLlamaExtraArgs = pendingLoadConfig?.llamaExtraArgs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Carry remembered arguments through runtime-only load paths

When a remembered GGUF is launched through a path that first calls applyModelLoadConfigToRuntime but does not attach that config to the selection, pendingLoadConfig is undefined here and the saved arguments are omitted. This affects the normal Hub launch in hub-page.tsx:1319-1338 and the training handoff in chat-page.tsx:3203-3216: applyPerModelConfigToRuntime has no field for these arguments, and /load only inherits from the same resident model, so a cold load or switch from another model silently runs without the arguments the user remembered. Pass the resolved config on those selections or otherwise carry its argument list into this load.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on both paths. applyPerModelConfigToRuntime has no field for the launch flags, and /load inherits them only from the SAME resident model, so the Hub launch and the training handoff started cold (or switched from another model) without the arguments the user had remembered.

Both selections now carry the resolved config itself: hub-page.tsx passes rememberedConfig on the selectModel call beside keepSpeculative, and the handoff in chat-page.tsx passes the same object it feeds to applyModelLoadConfigToRuntime. Neither invents one when nothing is remembered, so the field stays absent and a resident model keeps its own flags, which is what the omission path is for.

The settings-page Apply was already fixed this way earlier; these were the two that went through the runtime alone. Guarded by a source-level test next to that one.

Comment on lines +1315 to +1318
batchFloor: Math.max(
2,
config.nParallel ?? catalog?.defaultParallelSlots ?? 2,
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Size the batch floor with effective explicit slots

When the installed llama-server lacks --kv-unified and the user explicitly selects multiple Slots, this uses the requested count even though the backend clamps the launch to one slot. For example, Slots 4 with extra arguments --batch-size 2 is rejected in the editor and disables Load, while _effective_parallel_slots returns 1 and the backend accepts that exact command. The catalogue only publishes the effective default, so expose the clamp capability/effective selected count or leave this check to the backend when an explicit slot count may be clamped.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and it is the same gap the published default had, one level along. The default is effective, but an explicit Slots value is chosen in this panel and never passes through the route, so the editor could not apply the clamp without being told it exists: Slots 4 with --batch-size 2 was refused here while _effective_parallel_slots returns 1 and the backend accepts that exact command.

/api/inference/llama-flags now publishes parallel_slots_clamped, computed by asking the same helper the load uses (_effective_parallel_slots(2) == 1), in the same worker thread as the default so nothing new touches the event loop. An unreadable probe reports False, keeping the ask, as everywhere else here.

The three floor sites (the row, the collapsed revalidation, the hidden hydration check) now share one effectiveBatchFloor() so they cannot drift, and a backend that publishes neither field reads as not clamped, which is exactly the behaviour those builds had before. Tests: the clamp helper against a build with and without --kv-unified and against an unreadable probe, the single-slot default still reporting the clamp off the loop, and a source-level check that one definition backs all three call sites.

pre-commit-ci Bot and others added 5 commits August 14, 2026 12:09
… too

A launch that only applies the remembered config to the runtime lost the flags.
applyPerModelConfigToRuntime has no field for them, and /load inherits them only
from the SAME resident model, so the Hub launch and the training handoff ran a
cold start, or a switch from another model, without the arguments the user had
remembered for it. Both selections now carry the resolved config itself, and
neither invents one when nothing is remembered: the field stays absent, which is
what lets a resident model keep its own flags.

The batch floor had the same shape of gap as the published default did. That
default is effective, but an EXPLICIT Slots value chosen in the panel is not: a
build without --kv-unified serves one slot however many are asked for, so Slots 4
with '--batch-size 2' was refused in the editor while the backend, which clamps to
one, accepts exactly that command. The catalogue now publishes whether this build
clamps, computed by asking the same helper the load uses, and the three floor
sites share one function so the row and the two hidden checks cannot drift.

A backend that publishes neither field reads as 'not clamped', which is the
behaviour those builds had before.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

Before/after from two isolated Studio installs, the merge base 62b12f00 and the branch head bc09fac3, driven through the same panel for the same local GGUF (Qwen3.6-27B-MTP-GGUF, UD-Q4_K_XL). Nothing is loaded and no weights are read, so the panel is the only moving part.

Model settings panel, before and after the extra arguments row

Top pair, Advanced settings expanded and the box empty. Before, there is no extra-arguments control anywhere on the panel: the field existed only in the API and the CLI, so a per-model llama_extra_args override could be written through /api/settings/openai-auto-switch/overrides and then never seen or edited in a browser. After, the same panel, same model, same expansion, carries an Extra Arguments row between Chat Template and Advanced settings.

Bottom pair, --parallel 8 typed into it. Before there is nothing to type into and Load model stays live. After, the box reads back 2 arguments and a red line under it, --parallel is managed by Unsloth Studio and cannot be passed here., with Load model disabled.

Read from the same two servers that were photographed:

merge base 62b12f00 head bc09fac3
GET /api/inference/llama-flags 404 200
probe_ok route absent true
managed flags reported route absent 83
flags read from the installed llama-server --help route absent 395
default parallel slots route absent 4
diagnostics rendered under the box 0 1
Load model with --parallel 8 enabled disabled

The 404 on the base is the measurement as much as the missing box is: there was no catalogue for a browser to validate a flag against.

One thing to flag on the branch itself. 0ff185ae5 did not build: tsc -b refused the collapsed-panel hydration check because the catalogue literal it constructs was missing parallelSlotsClamped, so npm run build failed and no frontend bundle came out. bc09fac3 passes the clamp through from the managed-only read. That is the only difference between the two commits, and it is what the AFTER side above was built from.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc09fac33c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# without --kv-unified serves one slot however many were
# requested, and refusing a batch of 2 against it is a 400 on a
# command that would have run.
_effective_parallel_slots(_n_parallel, diffusion_kind = placement.diffusion_kind),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for embedding slot clamping in the batch check

For an embedding GGUF with a micro-batch below the requested slot count, this checks the extra --batch-size against the pre-clamp slots even though LlamaCppBackend.load_model later reduces embedding slots to the effective micro-batch. For example, n_parallel=4, n_ubatch=2, and extra args --batch-size 2 is rejected with 400 here on a --kv-unified build, while the launcher deterministically clamps to two slots and that command is valid; the equivalent /validate check has the same mismatch. Include the embedding/micro-batch clamp when deriving the floor.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. load_model reduces the slots to the effective micro-batch for an embedding GGUF before it launches, so n_parallel=4, -ub 2 and a pass-through --batch-size 2 launches at two slots where two is the floor, and both /load and /validate were refusing it.

Both floors now consult the same clamp, in _embedding_clamped_slots, layered on top of _effective_parallel_slots. It is asked only after check_batch_floor has already raised, so the GGUF header read is paid on the way to a 400 rather than on every load, and _is_embedding_gguf answers False for a header that cannot be read: the clamp can only ever turn a refusal into an acceptance, never the reverse, and never on a guess. Tests cover the clamped accept, the chat GGUF that is still refused, defaults clamping nothing, the floor of one slot, and -b 1, which stays refused because llama-server aborts on a batch of 1 at any slot count.

strip_shadowing_flags,
)

_validate_ngl_override = parse_gpu_layers_override(effective_extra_args)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate manual offload arguments before stripping them

In manual GPU-memory mode, this new translation runs before the later validate_extra_args call, so syntax that /load rejects can be parsed and removed before validation sees it. For example, /validate accepts llama_extra_args=["--gpu-layers=20"] after translating it to gpu_layers=20 and stripping the token, but /load validates the original list first and returns 400 because llama-server does not accept attached values; malformed values such as -ngl bad can also raise an uncaught ValueError here instead of the intended 400. Validate the explicit list before applying the same translation and strip used by the load path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it also swallowed the malformed case. Reproduced both: /validate accepted ["--gpu-layers=20"] by translating it to gpu_layers=20 and dropping the token, while /load validated the original list and returned 400 ("llama-server does not read an attached value"), and -ngl bad raised an uncaught ValueError out of the parser rather than a 400.

validate_extra_args now runs on the resolved list before the manual -ngl translation and strip_shadowing_flags, so the preflight judges the same list /load does. Covered in test_validate_diffusion_extra_args.py: the attached form is a 400 naming the two-argument spelling, -ngl bad is a 400 rather than a crash, and a well-formed -ngl 20 still passes.

Comment on lines +216 to +217
if (body.resolved !== undefined) {
return body.resolved?.llama_extra_args ?? [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve explicit empty arguments during override hydration

This collapses a resolved llama_extra_args: [] marker into the same return value as an override with no argument field, so callers cannot preserve the distinction the backend stores deliberately. When a quant has an explicit empty marker to suppress a bare-repository fallback while the same model is still resident with the old inherited arguments, ModelConfigPage sees stored.length === 0, leaves llamaExtraArgs undefined, and its next Load omits the field; /load then inherits the resident arguments instead of applying the saved clear. Return the field-presence state as well as its tokens so an explicit empty result hydrates to the explicit-clear state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and the tombstone exists precisely so that case is distinguishable. fetchLoadExtraArgs and resolveStoredExtraArgs now return {tokens, explicit}, and all three hydrating callers use it: the panel sets llamaExtraArgs to [] rather than leaving it undefined (only when nothing has been typed into the box in the meantime), and the background auto-load and the compare pane send the explicit empty list instead of omitting the field. Omitting it is what let /load inherit the resident model's arguments, which is the list the user had just cleared.

Covered by new cases in llama-extra-args-override-lookup.test.ts (a row carrying [] is explicit and stops the search; a row that matched on another field but carries no argument list is not explicit, so there is no clear to honour) and by the three call sites in llama-extra-args-panel-hydration.test.ts.

Three things the editor and the routes were judging differently from the
server.

Validate the pass-through list before the manual offload translation
rewrites it. In manual GPU memory mode /validate translated "-ngl" out of
the list and stripped the token before validate_extra_args ever saw it, so
"--gpu-layers=20" passed the preflight and /load answered 400 on the same
list, and a malformed "-ngl bad" raised an uncaught ValueError instead of
the 400 it was meant to be.

Size the batch floor from the slots an embedding GGUF actually serves.
--embedding caps the batch at the micro-batch and llama-server aborts when
that is below the slot count, so load_model reduces the slots to it before
launching: four slots with "-ub 2" and "--batch-size 2" launches at two
slots, where two is the floor, and both routes were refusing it. Read only
after the floor has already refused, so the header read is paid on the way
to a 400 rather than on every load, and only a positively classified
embedding GGUF relaxes anything.

Keep an explicit empty argument list apart from a missing one when the
panel hydrates. The settings page writes an empty list on purpose when the
box is cleared for a quant whose bare-repository row still carries
arguments; read as "nothing stored" the panel left the field undefined, its
next Load omitted it, and /load carried the resident model's arguments over,
which is exactly what had been cleared.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

https://github.com/unslothai/unsloth/blob/09bf23e9db5167c7397a56a72114f6c875d39264/studio/backend/core/inference/llama_cpp.py#L15450-L15452
P2 Badge Redact pass-through values before logging

When an allowed pass-through flag carries sensitive data, this INFO log writes every value verbatim to persistent application logs. Extra arguments can include bearer credentials or private template/configuration data, while the nearby command-log helper only redacts Studio's own --api-key; log only flag names/counts or apply secret-aware redaction before emitting the list.


https://github.com/unslothai/unsloth/blob/09bf23e9db5167c7397a56a72114f6c875d39264/studio/backend/routes/inference.py#L8094-L8097
P1 Badge Preserve llama.cpp's all-layers sentinel during translation

In Manual GPU Memory mode, an explicit -ngl -1 reaches this translation even though llama.cpp defines -1 as offload all layers (the repository's embedding launcher uses that exact value for full GPU offload). Assigning it to request.gpu_layers instead invokes Studio's different -1 sentinel—Auto/--fit—and the subsequent strip removes the original flag, so the launched placement can partially offload to CPU instead of honoring the requested all-GPU command; preserve this sentinel's llama.cpp meaning when translating or leave the raw flag intact.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

pre-commit-ci Bot and others added 4 commits August 14, 2026 13:28
The panel's explicit-clear hydration rests on a backend rule with no test of
its own: a save that clears the box for one quant keeps a row holding an
empty list, so a load stops there instead of falling through to a legacy
bare-repository row that still carries flags. Four cases now pin it: the
clear that suppresses the fallback while leaving the bare row for the other
quants, an empty save with nothing to suppress storing nothing at all, a
fill never writing one, and the flag deciding only what an empty list means.

The embedding classification behind the batch floor reads the GGUF header,
so run it in a thread: both routes reach it from an async handler that is
also serving download progress polls.
…nto flags-editor

# Conflicts:
#	studio/backend/routes/inference.py
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant