studio: apply saved per-model settings on API loads, add API monitor by shimmyshimmer · Pull Request #7473 · unslothai/unsloth · GitHub
Skip to content

studio: apply saved per-model settings on API loads, add API monitor - #7473

Merged
danielhanchen merged 102 commits into
mainfrom
studio/api-monitor-and-per-model-settings
Jul 31, 2026
Merged

danielhanchen merged 102 commits into
mainfrom
studio/api-monitor-and-per-model-settings

Conversation

@shimmyshimmer

Copy link
Copy Markdown
Member

The bug

If you save settings for a model and then load that model over the API, you get the model but none of the settings. You have to load it in the UI first to get the config you asked for.

The cause: the server side override map only ever held two fields, llama_extra_args and max_seq_length. Every other per-model setting (context length, KV cache dtype, speculative decoding, tensor parallel, GPU layers, GPU ids, CPU MoE offload, chat template override) lived only in browser localStorage. Auto-switch had no way to see them, so it loaded with app defaults.

The fix

Store the whole config server side and map it onto the same LoadRequest the picker builds, so a remote load and a picker load of the same model produce the same command line.

  • normalize_model_override validates and stores the full config. Bad values are dropped one at a time rather than rejecting the payload, so a stale field (a KV dtype this llama.cpp build lost, a GPU id from another host) costs you that field, not the whole config, and never fails the load that reads it.
  • model_override_load_kwargs maps the stored config to LoadRequest kwargs. GPU placement is gated on GGUF, matching the UI, so a safetensors model still loads through HF auto-placement and cannot inherit a hidden GGUF GPU pin.
  • resolve_fit_max_seq_length mirrors resolveFitMaxSeqLength in the UI: under Manual GPU memory with Auto layers, llama.cpp --fit owns context sizing, so the load sends the context pin rather than the stored max sequence length.
  • Overrides are keyed per quant (repo:Q4_K_M), since Q4_K_M and Q8_0 of the same repo are genuinely different setups. Lookup falls back to the bare repo id, then the target id.

Also in here

Two pieces of UI that were built alongside this and share the same model-overrides API, which is why they are in one PR rather than three.

API monitor. It was a cramped console inside the API settings tab. It is now a full page at /api-monitor with filters, search, and full request and reply payloads, plus a floating panel that opens itself when API traffic arrives and links through to the page. Settings still owns configuration; the monitor owns observability. DELETE /monitor is new and is subject-scoped, since every other read on the monitor already is, and an unscoped clear would let one caller erase another's history and zero their active count mid-generation.

Per-model settings as a page. Reachable from the Hub, so you can configure a model without loading it first.

Compatibility

  • Existing override entries behave identically. An old entry has only the two legacy keys, and the mapper emits exactly those two for it. test_auto_switch_applies_model_override and test_auto_switch_applies_partial_override pass unmodified, which is the proof.
  • Key lookup only widens. Bare-repo entries written before this change still resolve, covered by test_auto_switch_falls_back_to_bare_repo_override.
  • No new hardware path. Nothing NVIDIA, AMD, macOS or CPU specific changed, no new backend and no new install path.
  • Omitting llama_extra_args preserves it. The settings page has no control for launch flags, so a save from it omits the field; omitted has to mean leave alone or every UI save would silently wipe flags. An explicit empty list is still how you forget a model.

Tests

199 passing across test_openai_auto_switch.py and test_api_monitor.py, including 9 new ones covering field-dropping and GPU id dedupe in normalize_model_override, byte-limited chat templates, spec_draft_n_max only being stored for MTP modes, --fit context sizing, GGUF gating in the mapper, both override key resolution paths, the launch-flag carry-over contract, and subject-scoped monitor.clear().

The mapper test constructs a real LoadRequest from the kwargs it produces, so a key that is not a real field fails in CI rather than as a TypeError the moment a user's request arrives.

tsc -b and npm run build are clean.

Worth knowing before merging

  • I have not visually verified the UI. The backend logic is unit-tested and the frontend type-checks, but I did not have a browser available while writing this. The monitor page and panel deserve a look before this goes in.
  • The floating panel opening itself is a behaviour change everyone running the API server will notice. There is an off switch in the panel and a toggle in API settings to turn it back on, but it defaults to on.
  • localStorage and the server copy can still drift. Clearing browser data leaves the server entries behind. The saved-settings list on the monitor page is deliberately read only for that reason: the model's settings page is the only place that owns both halves and can forget a model completely.
  • test_hf_cache_settings.py::test_xet_only_override_keeps_model_cache_editable fails on this branch, but it also fails on a clean main. Pre-existing and unrelated.

Per-model settings were mirrored to the server as two fields only,
llama_extra_args and max_seq_length. Everything else lived in browser
localStorage, so a model loaded by an API request came up with app
defaults for context length, KV cache dtype, speculative decoding,
tensor parallel and GPU placement.

Store the full config server side and map it onto the same LoadRequest
the picker builds, so a remote load and a picker load of the same model
produce the same command line. Entries are keyed per quant, falling back
to the bare repo id so existing entries keep resolving.

Also moves the API monitor out of the settings tab: a full page at
/api-monitor, plus a floating panel that opens itself when traffic
arrives, and a settings page per model reachable from the Hub.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

@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: b5cada1028

ℹ️ 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 +3729 to +3732
for override_key in (
f"{override_id}:{variant}" if variant else None,
override_id,
target_id,

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 Look up variant-qualified concrete model paths

When the settings UI saves a multi-quant GGUF whose target.id is a concrete local/snapshot path, it stores the override as path:VARIANT. Auto-switch resolves that model to a public override_id plus the concrete target_id, but this loop checks only override_id:VARIANT, override_id, and the unqualified target_id. Consequently the saved settings are silently ignored for local folders and models in non-active Hugging Face caches; include target_id:variant in the lookup.

Useful? React with 👍 / 👎.

source: "local",
ggufVariant: target.ggufVariant ?? undefined,
isGguf: target.isGguf,
isDownloaded: true,

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 the partial-download state when loading settings

When settings are opened for a partial inventory row, target.meta.isDownloaded is deliberately set to false, but this load call hardcodes it to true. The model runtime therefore treats the operation as a cached load, skips download-progress polling, and shows “Starting model” while the remaining weights are still downloading; pass through target.meta.isDownloaded so partial downloads retain their progress and phase reporting.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/settings.py Outdated
Comment on lines +344 to +345
if requested_extra_args is None and not is_removal:
requested_extra_args = get_model_override(payload.model_id).get("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 bare-repo launch flags when creating quant overrides

When a pre-existing override stores llama_extra_args under the bare repo ID, the first settings save for a GGUF now uses repo:VARIANT; this exact-key lookup finds no flags, creates a nonempty variant entry without them, and auto-switch then prefers that entry over the bare fallback. Since the new settings UI cannot display or resend launch flags, saving any per-quant setting silently disables the user's legacy flags; carry them over from the bare-repo entry when the qualified entry does not yet exist.

Useful? React with 👍 / 👎.

Comment on lines +500 to +501
const baseUrl =
typeof window === "undefined" ? "" : `${window.location.origin}/v1`;

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 Build the displayed API URL from the reachable backend base

In the packaged Tauri app the backend is reached through getApiBase()/the dynamically selected localhost port, while window.location.origin is the Tauri webview origin rather than the API server. The monitor therefore displays and copies an unusable tauri://…/v1 URL (and can also miss the fallback port); derive this value from the platform server URL or getApiBase() on desktop, as the existing Agents settings tab does.

Useful? React with 👍 / 👎.

Comment on lines +1259 to +1261
downloaded.find((v) =>
ggufVariantsMatch(v.quant, activeGgufVariant),
)?.quant ??

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 Gate the loaded-quant preference by model identity

When another GGUF model is currently loaded, this selection still prefers its active quant for the row being opened. Because common quants such as Q4_K_M appear in many repositories, opening model B's settings while model A uses Q4_K_M silently targets B's Q4_K_M instead of B's configured default, so subsequent saves and loads affect an unintended variant; only prefer activeGgufVariant when activeCheckpoint matches this row.

Useful? React with 👍 / 👎.

Comment on lines +343 to +347
parsed = _bounded_int(gid, minimum = 0, maximum = 1024)
if parsed is not None and parsed not in cleaned_ids:
cleaned_ids.append(parsed)
if cleaned_ids:
entry["gpu_ids"] = cleaned_ids

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 Revalidate saved GPU IDs before auto-switch loads

These IDs are validated only as integers in a broad numeric range, so an override saved before a GPU removal, visibility-mask change, or move to another host retains IDs that are no longer available. On the next API auto-switch, _validate_requested_gpu_ids calls resolve_requested_gpu_ids and turns that stale field into a 400, failing the entire model load rather than dropping only the unusable setting as intended; reconcile the stored IDs with the current backend devices at load time and omit the field when invalid.

Useful? React with 👍 / 👎.

Unsloth and others added 2 commits July 26, 2026 07:36
Review findings (PR #7473):
- Look up overrides under the concrete load path with its quant, not just the
  advertised repo id, so local folders and non-active HF caches are found.
- Carry bare-repo launch flags into the first per-quant save. Auto-switch
  prefers the qualified entry, so without this the flags were silently dropped
  and no UI could show or restore them. The bare id is only derived when the
  suffix looks like a quant, so a Windows drive letter is not split.
- Drop a saved gpu_ids pin that no longer resolves instead of 400ing the whole
  load. A pin outlives the machine it was made on.
- Build the displayed API base from getApiBase() on desktop; the Tauri webview
  origin is not the API server.
- Keep a partial download's isDownloaded when opening settings, so the loader
  still reports download progress.
- Only prefer the loaded quant when the loaded model is this row. Q4_K_M exists
  in most repos, so an unguarded match targeted the wrong variant.

Found by simulation:
- A lone surrogate in a chat template raised UnicodeEncodeError on the byte
  check, an unhandled 500. Now a validation error, in all three call sites.
- _bounded_int accepted bools as GPU ids, truncated fractional floats, and
  raised OverflowError on Infinity, which json.loads accepts.
- api_monitor stored a non-string model verbatim; the monitor page then threw
  on toLowerCase and rendered nothing. Coerced at the boundary and the filter
  no longer trusts network data.
- The overlay store now uses storage that cannot throw. Safari private mode and
  blocked-cookie origins make localStorage throw on access, which broke the
  opt-out toggle.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Worked through all six review comments. All six were real, and I have a regression test for each of the backend ones. Details below, plus a separate batch of bugs found by fuzzing this in a sandbox.

Review findings

P1, variant-qualified concrete paths. Correct. A local folder or a non-active HF cache resolves to a public repo id plus a concrete path, and settings saved against the path were invisible. The loop now tries override_id:variant, target_id:variant, override_id, target_id, most specific first. Tests: test_override_found_under_a_concrete_path_with_variant, test_repo_qualified_override_beats_path_qualified.

P1, stale GPU ids. Correct, and it contradicted the design note I had written two functions earlier saying a stale field must never fail the load. normalize_model_override cannot know the device list, so reconciliation has to happen at load time. Added _override_gpu_ids_still_resolve, which drops the pin and logs rather than 400ing the whole load. It swallows hardware errors deliberately, since it runs on the load path. Tests: test_stale_gpu_ids_are_dropped_not_fatal, test_usable_gpu_ids_are_kept, test_override_gpu_ids_probe_never_raises.

P1, Tauri base URL. Correct. window.location.origin is the webview origin, not the API server, and the packaged app picks its port dynamically. Now uses the same isTauri ? (serverUrl ?? getApiBase()) : origin source as the Agents tab.

P2, bare-repo launch flags. Correct and the nastiest of the six, because the failure is silent and unrecoverable through the UI. Carry-over now falls back to the bare repo entry when the qualified one does not exist yet. The bare id is only derived when the suffix actually looks like a quant (no path separator, at most 64 chars), so C:\models\x.gguf is not split into C plus a fake variant. Tests: test_first_quant_save_keeps_legacy_bare_repo_launch_flags, test_bare_repo_carry_over_does_not_split_a_windows_path, test_windows_path_with_quant_still_carries_over.

P2, partial download state. Correct, and it contradicted my own comment three lines above the bug, which explicitly said a partial row must not claim to be complete. Now passes target.meta.isDownloaded through.

P2, loaded-quant preference. Correct. Gated on the loaded model actually being this row, matched with the existing modelIdsMatch helper across the row's aliases rather than a new comparison.

Found separately by fuzzing

I ran this in isolated sandboxes (uv venv for the backend logic, a separate vitest project for the frontend) over Windows and POSIX path shapes, unicode model ids, hostile JSON, and concurrent writes. 186 backend cases and 35 frontend cases. That turned up four more, all fixed here:

  • A lone surrogate in a chat template was an unhandled 500. JSON.stringify emits \ud800 for a truncated emoji paste, Python's json decodes it happily, and .encode("utf-8") then raises UnicodeEncodeError. Hit all three byte-check call sites, including the pre-existing one in picker/schemas.py. Now a clean validation error.
  • _bounded_int was too permissive. bool is a subclass of int, so gpu_ids: [true, false] pinned the model to GPUs 1 and 0. int(1.5) silently became a context of 1. Infinity, which json.loads accepts, raised OverflowError.
  • A non-string model broke the monitor page. api_monitor stored it verbatim, and the page then threw on toLowerCase and rendered nothing until the ring buffer rolled over. Coerced at the boundary, and the filter no longer trusts network data.
  • The opt-out toggle threw in Safari private browsing. localStorage throws on access there and on cookie-blocked origins, which zustand's default storage does not guard. Now behind storage that cannot throw.

Baseline check: 49 backend tests fail on this branch, and the same 49 fail on a clean main, so no new failures.

Still outstanding and worth a human eye before merge: I have not visually verified the monitor page or the floating panel in a browser, and the panel opening itself is a default-on behaviour change (there is an off switch in the panel and a toggle in API settings).

@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: 9dee5ac2d0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +3990 to +3995
is_vulkan = LlamaCppBackend._is_vulkan_backend()
if get_device() == DeviceType.XPU and not is_vulkan:
# gpu_ids is rejected outright on XPU.
return False
resolve_requested_gpu_ids(gpu_ids, is_vulkan = is_vulkan)
return True

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 Fully validate saved Vulkan GPU ordinals

On a Vulkan backend, resolve_requested_gpu_ids(..., is_vulkan=True) only checks for duplicate or negative ordinals, so a saved ordinal from another host still returns True here. _resolve_gguf_gpu_ids_for_request later probes the actual Vulkan devices and raises a 400 when that ordinal is absent, causing the auto-switch load to fail instead of dropping the stale field. The fresh evidence beyond the existing stale-GPU finding is this mismatch between the new reconciliation helper and the later Vulkan-specific probe; use the same device probe before retaining the override.

Useful? React with 👍 / 👎.

Comment on lines +137 to +142
// Launch flags have no UI control, so the backend preserves them when the
// field is omitted. Forgetting a model means forgetting all of it, so that
// path sends an explicit empty list to clear them.
// biome-ignore lint/style/useNamingConvention: API schema
...(config === null ? { llama_extra_args: [] } : {}),
...toApiOverride(config),

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 Distinguish saving defaults from forgetting the model

When the user keeps “Remember for this model” enabled but the effective configuration is entirely default, toApiOverride(config) returns {} while config !== null, so this sends only model_id. The backend explicitly interprets that payload as removal, which deletes any pre-existing llama_extra_args even though the UI cannot display or edit them; the fresh unhandled path beyond the earlier launch-flag finding is a non-null default save, rather than a save containing another field. Preserve a distinct save/remove signal so only the explicit config === null path clears those flags.

Useful? React with 👍 / 👎.

Comment on lines +889 to +894
if (!saveFailed) {
syncModelOverride(
target.id,
target.ggufVariant,
remember ? effectiveRuntimeConfig : null,
);

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 Migrate existing per-model settings to the server

For users upgrading with entries already stored in unsloth_model_configs, the only repo-wide call to syncModelOverride is this action handler, so none of those existing settings reach the new backend override map until the user opens every model and explicitly saves or loads it again. An API auto-switch immediately after upgrade therefore loads those models with app defaults despite the UI still showing them as remembered; add a one-time reconciliation of existing local entries when the authenticated frontend starts or when the new feature is initialized.

Useful? React with 👍 / 👎.

Comment on lines +113 to +119
useEffect(() => {
if (onFullPage) {
return;
}
let cancelled = false;
let timer: number | undefined;
const intervalMs = isOpen ? OPEN_POLL_MS : IDLE_POLL_MS;

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 Stop polling after automatic monitor opening is disabled

When autoOpen is false and the overlay is closed, this effect still starts an immediate request and continues calling /api/inference/monitor every five seconds indefinitely. Since the panel cannot open or display those results in that state, opting out leaves every open Studio window generating permanent authentication, network, and backend polling load; short-circuit the effect when both !autoOpen and !isOpen, and include autoOpen in its dependencies so toggling the preference actually stops or restarts the loop.

Useful? React with 👍 / 👎.

- Probe actual Vulkan devices in the GPU reconciliation helper. Vulkan ordinals
  are their own index space, so resolve_requested_gpu_ids only rejects malformed
  ones; the helper said "usable" and the load then 400d on the ggml probe it had
  skipped.
- Send explicit save/remove intent. A save whose config is entirely default
  carries no fields, which is shape-identical to "forget this model", so it was
  wiping launch flags the UI cannot show or restore. A bare model_id without the
  flag still removes, keeping the original contract.
- Backfill existing per-model settings into the server map once after upgrade.
  Without it, settings saved before this change never reached the server, so an
  API load used app defaults while the UI still showed the model as remembered.
  Never overwrites a server entry and retries until it fully succeeds.
- Stop polling the monitor endpoint when auto-open is off and the panel is
  closed. It cannot open or display anything in that state, so every open Studio
  window was polling every five seconds for nothing.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Second round: all four were real. Fixed with a regression test each.

P1, Vulkan ordinals. Right, and it was a hole in the fix I had just added. resolve_requested_gpu_ids(..., is_vulkan=True) only rejects duplicates and negatives, because Vulkan ordinals are their own index space, so my helper returned "usable" for a stale ordinal and the load then 400d on the ggml probe further down. The helper now runs that same probe. A missing llama-server binary is treated as usable rather than unusable, so a valid pin is not dropped on every load when there is nothing to probe with; the later path stays the authority there. Tests: test_vulkan_ordinal_absent_from_the_probe_is_unusable, test_vulkan_probe_without_a_binary_does_not_block_the_load.

P2, default save versus forget. Right, and the nastiest kind of bug: two different intents with identical payload shapes. A save with "Remember for this model" on but every value at its default produces {} from toApiOverride, so the body was a bare model_id, which the route reads as removal. Rather than widen the inference I made the intent explicit: the payload now carries remove, the frontend sets it on both paths, and a bare model_id with the flag absent still removes so the original contract and its test are untouched. Tests: test_default_save_preserves_flags_instead_of_removing, test_explicit_remove_still_clears_everything, test_bare_payload_without_remove_flag_still_removes, test_remove_false_with_real_fields_saves_normally.

P2, migrating existing settings. Right, and it was the biggest gap in the PR, since it meant the headline fix only applied to settings saved after upgrading. Everyone already using per-model settings would have kept getting app defaults on API loads while the UI still said "remembered". Added a one-time backfill after auth that pushes local entries the server has never seen. It never overwrites a server entry, and it only sets the done flag on full success, so an offline start or a partial failure retries next launch instead of silently skipping the migration forever. Eight cases covered including the quant-qualified key format, partial failure, and storage being denied.

P2, polling while opted out. Right. The effect did not depend on autoOpen, so turning the panel off left every open window polling every five seconds forever for results it could not display. Guarded and autoOpen added to the deps. It still polls if the panel was opened by hand while opted out, which is the one case where the data is actually on screen.

Verification for this round: 213 backend tests pass, plus 48 in a separate frontend sandbox. Full suite is still at parity with clean main, so no new failures. tsc and the production build are clean, and I ran the repo's own ruff and kwarg-spacing hooks locally so this round should not need another pre-commit.ci fixup.

Unchanged from before: I still have not visually verified the monitor page or the floating panel in a browser, so that is the one thing worth a human pass before merge.

@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: 04e8beec62

ℹ️ 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".

continue;
}
try {
await putModelOverride(entry.modelId, entry.ggufVariant, entry.config);

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 original model-key casing during backfill

Existing v2 localStorage keys are produced by modelStorageKey after normalizing both repo IDs and GGUF variants to lowercase, and listPerModelConfigs returns those normalized values, so this call persists keys such as org/model:q4_k_m. Auto-switch performs exact dictionary lookups using resolver casing such as Org/Model:Q4_K_M, meaning the migrated entry is never found and upgraded users still load with defaults. This is fresh evidence beyond the prior missing-migration comment: the newly added migration writes keys that cannot match; preserve the original identifiers or normalize both sides of the server lookup.

Useful? React with 👍 / 👎.

Comment on lines +499 to +500
lastFetchedRef.current = revision;
requestDetail(selectedId_);

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 Retry detail revisions skipped during an in-flight fetch

When the selected request advances to a new revision while its previous detail fetch is still running, requestDetail rejects this call through its in-flight guard, but lastFetchedRef has already recorded the new revision. If that race occurs on the terminal update, the older fetch can return a running or otherwise stale payload, updated_at never advances again, and this effect permanently skips the terminal detail, leaving only the truncated row preview visible. Record the revision only when a fetch actually starts or queue a retry after the in-flight request settles.

Useful? React with 👍 / 👎.

Comment on lines +1281 to +1282
const leaf = id.split(/[\\/]/).filter(Boolean).pop() ?? id;
setSettingsTarget({

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 Ignore stale asynchronous settings opens

Opening settings for a variant-required GGUF waits for listGgufVariants, but there is no request identity or cancellation check before committing this target. If the user opens row A and then row B while A's lookup is still pending, whichever lookup finishes last overwrites settingsTarget; the page can therefore display, save, or load settings for A after the user most recently selected B. Track the latest requested row and discard results from older lookups before calling setSettingsTarget.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/settings.py Outdated
mode) are left to it, since their valid sets follow the llama.cpp build.
"""

model_id: str = Field(..., min_length = 1, max_length = 512)

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 Accept loader-valid local paths as override keys

Local inventory targets use their concrete filesystem path as model_id, optionally with a quant suffix, but this new 512-character limit is stricter than LoadRequest.model_path, which has no corresponding bound, and valid filesystem paths can be substantially longer. For such a model the local settings save succeeds, while the best-effort server sync receives a 422 and only logs a warning, so the UI continues to show the configuration as remembered even though API auto-switch loads it with defaults. Remove this arbitrary limit or align it with the actual loader and request-size constraints.

Useful? React with 👍 / 👎.

- Fall back to a case-insensitive override lookup. The browser normalizes ids to
  lowercase before storing, so the backfill wrote "org/model:q4_k_m" while the
  resolver asked for "Org/Model:Q4_K_M" and never matched, which made the whole
  migration a no-op. Exact match still wins and an ambiguous fallback matches
  nothing, so two POSIX paths differing only in case stay distinct.
- Record a detail revision only when a fetch actually started. requestDetail's
  in-flight guard can refuse, and recording anyway meant a revision that landed
  during an earlier fetch was skipped for good once updated_at stopped moving,
  leaving a terminal request showing a stale running payload.
- Discard superseded settings opens. The GGUF variant lookup is async, so
  opening a second row while the first was pending let whichever finished last
  win, and the page could then save or load settings for the wrong model.
- Raise the model_id cap to PATH_MAX plus a quant suffix. A local model's id is
  its filesystem path and LoadRequest.model_path is unbounded, so the old 512
  limit 422d the server sync while the local save succeeded, leaving the UI
  showing settings the API would never apply.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

@codex review

@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: 8f322aa627

ℹ️ 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 thread studio/backend/routes/settings.py Outdated
Comment on lines 385 to 388
set_model_override(
payload.model_id,
llama_extra_args = extra_args,
max_seq_length = payload.max_seq_length,

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 Honor remove even when config fields are present

When a client sets remove: true while reusing a payload that still contains fields such as max_seq_length, is_removal only disables the launch-argument carry-over; these fields are still passed to set_model_override, so the entry is re-created and the endpoint returns success without removing it. Treat an explicit removal as an unconditional empty override rather than forwarding the remaining payload.

Useful? React with 👍 / 👎.

Comment on lines +3998 to +4003
resolved = resolve_requested_gpu_ids(gpu_ids, is_vulkan = is_vulkan)
if is_vulkan and resolved:
# Vulkan ordinals are their own index space, so resolve() only rejects
# malformed ones. Presence needs the same ggml probe the load does.
binary = LlamaCppBackend._find_llama_server_binary()
if binary:

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 Reject Vulkan diffusion GPU pins in the preflight

When a DiffusionGemma override with saved gpu_ids is replayed on a Vulkan host, this preflight returns true as long as the ordinals appear in the probe. _resolve_gguf_gpu_ids_for_request then unconditionally rejects GPU selection for Vulkan diffusion models, so the auto-switch load still fails with 400 instead of dropping the incompatible saved field as intended for cross-host settings.

Useful? React with 👍 / 👎.

Comment on lines +499 to +503
// Only remember the revision when a fetch really started; the in-flight
// guard can refuse, and recording it anyway skips that revision for good.
if (requestDetail(selectedId_)) {
lastFetchedRef.current = revision;
}

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 Retry detail revisions skipped by the in-flight guard

When a selected streaming entry already has cached details, a new updated_at revision can arrive while the preceding detail fetch is still running. requestDetail then returns false, but completing that fetch changes neither selectedIsMissing nor any other effect dependency, so if this is the terminal revision it is never fetched and the panel remains on the truncated row preview. Trigger a retry when the in-flight request finishes or track the skipped revision explicitly.

Useful? React with 👍 / 👎.

Comment on lines +181 to +186
useEffect(() => {
if (isAuthFlowRoute) {
return;
}
void backfillModelOverrides();
}, [isAuthFlowRoute]);

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 Run the desktop backfill after authentication is ready

In the Tauri app, this RootLayout effect runs before AppProvider has finished starting the backend and performing tauriAutoAuth; if the backend remains unavailable beyond authFetch's startup retries, backfillModelOverrides returns with its done flag unset. Since isAuthFlowRoute never changes when desktop authentication later succeeds, the effect does not retry, so upgraded desktop users' existing per-model settings are not mirrored during that session and API loads continue using defaults.

Useful? React with 👍 / 👎.

ggufVariant: string | null | undefined,
config: PerModelConfig | null,
): void {
void putModelOverride(modelId, ggufVariant, config).catch(

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 Serialize writes for each model override

Each save launches an independent full-replacement PUT without sequencing it against earlier saves for the same model. If a user applies two edits quickly and the first request completes after the second, its older payload becomes the final server value even though localStorage contains the newer config, causing subsequent API-triggered loads to use stale settings until another save succeeds. Queue or supersede writes per override key so completion order cannot reverse the user's changes.

Useful? React with 👍 / 👎.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

Third round: all four correct. One of them meant my previous fix did nothing at all, so thanks for catching it.

P2, casing in the backfill. This is the important one. modelStorageKey stores the normalized identity, and normalization lowercases repo ids and quants, so listPerModelConfigs hands back unsloth/qwen3-8b-gguf / q4_k_m. The migration I added last round was writing keys that auto-switch, which asks with the resolver's real casing, could never match. The migration was a no-op for every model whose id or quant has an uppercase letter, which is essentially all of them. I confirmed it by printing both sides:

migration writes:   unsloth/qwen3-8b-gguf:q4_k_m
auto-switch reads:  unsloth/Qwen3-8B-GGUF:Q4_K_M

Fixed on the server side rather than the writer, since the original casing is not recoverable from storage and the mismatch can arise from any client. get_model_override now falls back to a case-insensitive match when nothing matches exactly. Exact match still wins, so two POSIX paths differing only in case keep their own entries, and an ambiguous fallback deliberately matches nothing rather than guessing which model's settings to apply. Tests: test_override_lookup_falls_back_to_case_insensitive, test_exact_override_match_beats_a_case_variant, test_ambiguous_case_fallback_matches_nothing, plus an end-to-end one through the load kwargs.

P2, detail revisions skipped during an in-flight fetch. Right, and the failure mode is exactly as described: requestDetail refuses while a fetch for that id is running, but the effect had already recorded the revision, so if that happened on the terminal update the payload froze as "running" forever with no retry. requestDetail now returns whether it actually started, and the revision is recorded only then.

P2, stale asynchronous settings opens. Right. listGgufVariants is awaited between picking the row and committing the target, with no identity check, so opening A then B could leave B's row showing A's settings and then save or load against A. Added a per-open sequence guard.

P2, model_id length. Right, and the limit was mine and arbitrary. A local model's id is its filesystem path, LoadRequest.model_path is unbounded, and the failure was silent in the worst way: the local save succeeded, the server sync 422d into a logged warning, and the UI kept showing the model as remembered while API loads used defaults. Raised to PATH_MAX plus the quant suffix, expressed as 4096 + 1 + _MAX_VARIANT_SUFFIX_LEN so it is tied to a real constraint rather than a round number.

216 backend tests and 53 in the frontend sandbox, all passing. tsc, the production build, ruff and the kwarg-spacing hook are clean locally, so this push should not need a pre-commit.ci fixup.

On CI: it has not completed a run on this branch yet, but that is not this PR. My three pushes each cancelled the previous SHA's in-flight jobs, and the repo's runners are currently backed up with 63 jobs queued or pending across all branches. I have stopped pushing so the current SHA can finish.

Still unchanged: the monitor page and floating panel have not been visually verified in a browser.

CI caught this: the Chat UI Playwright run failed because the floating panel
opened during an ordinary chat turn and its Expand button covered the
composer's Send button.

The panel opened because Studio's own chat goes through the same tracked
endpoints as the OpenAI-compatible API, so any conversation looked like API
traffic. That is wrong regardless of the click interception: this panel exists
for when Unsloth is being used as an API server, not when someone is using
Unsloth.

Record on each monitor entry whether the caller authenticated with an
sk-unsloth key rather than a UI session, and auto-open only for those. The
discriminator already existed for other routes; this reuses it.

@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: 0a49dfd047

ℹ️ 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 +451 to +455
folded = model_id.casefold()
matches = [
value
for key, value in overrides.items()
if isinstance(key, str) and key.casefold() == folded and isinstance(value, dict)

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 case sensitivity for concrete local paths

When a concrete POSIX path has no exact override, this fallback can return the settings for a different file whose path differs only by case. For example, an override for /models/foo.gguf is applied when the resolver loads /models/Foo.gguf if only the former entry exists, potentially replaying an incompatible context, chat template, or GPU configuration. The fresh evidence beyond the earlier migration-casing issue is that the fallback is also used for concrete filesystem targets, where casing identifies distinct models; restrict case folding to repository/quant identifiers rather than local paths.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/settings.py Outdated
Comment on lines +345 to +349
head, sep, tail = model_id.rpartition(":")
if not sep or not head or not tail:
return None
if len(tail) > _MAX_VARIANT_SUFFIX_LEN or "/" in tail or "\\" in tail:
return None

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 Do not parse arbitrary POSIX colons as quant separators

On POSIX, a valid unqualified local model path can contain a colon, such as /models/foo:bar.gguf. This parser treats the suffix as a quant because it is short and contains no separator, so the first settings save can look up /models/foo and silently graft that unrelated model's llama_extra_args onto the local model. Only perform the bare-key carry-over when the caller actually supplied a GGUF variant or validate the suffix as a known quant.

Useful? React with 👍 / 👎.

Comment on lines +169 to +172
if (!seededRef.current) {
seededRef.current = true;
seenIdsRef.current = new Set(ids);
return;

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 Detect API traffic present in the initial snapshot

If the first external API request starts after the Studio page mounts but before this initial monitor fetch returns, the first snapshot is already nonempty and its request ID is seeded as history. Subsequent polls see the same ID, so the advertised floating monitor never opens for that request, even if it remains active or completes; it opens only when another request arrives. Seed existing history separately from requests that are live during initialization, or establish the baseline before starting the polling 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.

Done. The first snapshot seeds finished entries only:

seenIdsRef.current = new Set(
  data.entries.filter((entry) => entry.status !== "running").map((entry) => entry.id),
);

A request still running when that snapshot lands started while Studio was loading, so it is unseen live traffic rather than history, and the seeding branch falls through to the open check when any such entry came in via an API key.

Comment on lines +743 to +744
const settingsAction =
!isDataset && onOpenSettings ? { onOpen: () => onOpenSettings(row) } : undefined;

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 Restrict API-backed settings to auto-switchable models

This exposes the settings action for every downloaded non-dataset row, including Transformers/safetensors models, and the resulting page says and persists that configuration as settings used by API loads. However, _maybe_auto_switch_model delegates to resolve_local_gguf, whose index explicitly excludes Transformers/safetensors models, so when another model is loaded an API request naming such a row does not load it or apply these settings and can instead be served by the current model. Gate the API-load claim/server mirror to GGUF rows or extend the resolver to support the other formats.

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 mirror is already GGUF gated. model-config-page.tsx writes the local config for any model but only calls syncModelOverride when target.isGguf, for the reason given: the auto-switch resolver indexes GGUFs, so a safetensors entry would advertise settings on the applied-on-API-load list that no request can apply.

Keeping the row action ungated is intended. A safetensors model is still configurable by the picker, and its settings are honoured on a normal load. Only the server mirror is meaningless for it, and that is where the gate sits. There is no API-load claim in the page copy to gate.

Added test_only_gguf_configs_are_mirrored_to_the_server in 6ad8cb6 so the gate cannot be removed silently.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

CI found a real bug in this PR, and it is the one I kept flagging that I could not check myself.

Chat UI Tests failed on three runners. Playwright could not click Send:

<button ... "Expand to full monitor" ...> from <div class="pointer-events-none fixed inset-0 z-50">
  subtree intercepts pointer events

That is my floating panel sitting on top of the composer. But the interception is the symptom, not the bug. The bug is that the panel opened at all during an ordinary chat turn.

Studio's own chat goes through the same endpoints the monitor tracks, so every conversation looked like API traffic and popped the panel open. That is wrong independently of the click: this panel is for when Unsloth is being used as an API server, not for when someone is using Unsloth. Anyone chatting normally would have had it appear on them repeatedly.

Fixed by recording on each monitor entry whether the caller authenticated with an sk-unsloth key rather than a UI session, and auto-opening only for those. The discriminator already existed (authenticated_via_api_key, used elsewhere to treat programmatic callers differently), so this reuses it rather than inventing a heuristic. All nine api_monitor.start sites now pass it, and the helper is written so a malformed request object reads as "not an API key" rather than raising, since it runs on the hot path of every tracked request.

Tests: test_api_monitor_records_whether_the_caller_used_an_api_key, test_request_used_api_key_distinguishes_key_from_session, plus three overlay cases covering chat-only traffic, API-only traffic, and the two interleaved. 218 backend tests and 56 in the frontend sandbox.

Worth saying plainly: I had flagged the unverified UI as the main risk on this PR from the first comment onward, and this is exactly that risk landing. The Playwright suite caught something my unit tests structurally could not. I have left the panel's position alone, since it matches the existing Live monitor's bottom-4 right-4, and with the auto-open now correctly scoped the collision should not arise in the chat flow.

CI is still working through the queue on the new SHA (the repo's runners are backed up with ~67 jobs pending across all branches), so I will keep watching rather than assume this is the last of it.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

@codex review

@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: 0a49dfd047

ℹ️ 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 +4015 to +4016
resolved = resolve_requested_gpu_ids(gpu_ids, is_vulkan = is_vulkan)
if is_vulkan and resolved:

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 Drop saved Vulkan diffusion GPU pins before loading

When auto-switching a DiffusionGemma GGUF on a Vulkan build whose saved override contains gpu_ids, this helper returns true whenever the ordinals exist. _load_model_impl then reaches _resolve_gguf_gpu_ids_for_request, whose _classify_diffusion_gguf(config) is True branch rejects every Vulkan GPU selection with HTTP 400, so the saved setting prevents the API load instead of being discarded as intended. Include model classification in this reconciliation and remove the pin for Vulkan diffusion models.

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.

Not reproducible: this path already succeeds. The retry directly below the pre-flight catches a 400 whose detail mentions gpu, drops the pin and loads again, and the Vulkan diffusion rejection is exactly the case it was written for. test_load_retries_without_gpu_ids_when_the_loader_rejects_the_pin in test_openai_auto_switch.py asserts it, with the diffusion refusal as the injected error.

The pre-flight deliberately does not classify the model: _classify_diffusion_gguf needs a ModelConfig that is not built at that point, and duplicating loader rules there means they drift. What was wrong is the docstring, which claimed the pre-flight mirrors every check the loader makes. Corrected in 6ad8cb6 to say it covers the device-availability rules (the ones that go stale) and that the retry covers the rest, including rules added later.

Comment on lines +1278 to +1281
} catch {
// Offline or an unreadable cache: fall through with no variant. The
// settings page still works, it just cannot pin a specific quant.
ggufVariant = null;

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 Avoid saving required-variant settings under a bare key

When variant discovery fails for a downloaded GGUF row that requires a variant, this fallback still opens the editor with ggufVariant = null. Saving then writes the browser config under the bare model ID, but resolveInitialConfig matches variants exactly, so a later picker load for Q4_K_M, for example, will not find these settings; meanwhile the backend auto-switch deliberately falls back to the bare override and does apply them. This recreates a UI/API configuration mismatch, so either resolve a concrete variant before opening or prevent saving until one is available.

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.

Done, and taken further than suggested. When a required variant cannot be resolved the editor does not open at all: it shows an error toast and returns, rather than opening against a bare key. Saving under a key the picker matches exactly but the API falls back to is the mismatch worth avoiding, so refusing to open is the safer half of the either/or.

Comment on lines +1349 to +1354
const openSelectedModelSettings = useCallback(
(ggufVariant: string | null) => {
if (!selectedModel) return;
const id = selectedModel.resource.runId;
const leaf = id.split(/[\\/]/).filter(Boolean).pop() ?? id;
setSettingsTarget({

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 Invalidate pending row lookups when opening detail settings

If a row's listGgufVariants request is still pending, opening settings from another model's detail card does not increment settingsOpenSeq. The older row request therefore still passes the guard at line 1287 when it finishes and replaces settingsTarget with the wrong model. Increment the shared sequence in this entry point before setting the target so every new settings open invalidates earlier asynchronous lookups.

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.

Done. settingsOpenSeq.current += 1 is in the detail-card handler as well as in openModelSettings, so an open from either entry point invalidates a lookup still pending from the other.

Comment on lines +1790 to +1791
{settingsTarget && (
<div className="hub-canvas absolute inset-0 z-30 flex min-h-0 flex-col">

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 Make the covered detail view inert

When settings are opened from a model detail view, this higher-z-index overlay visually covers the detail pane, but only the catalog sibling is made inert. The still-mounted HubDetailView remains in keyboard focus order and the accessibility tree, so tabbing past the settings controls can operate or announce controls hidden behind the overlay. Mark the detail pane inert while settingsTarget is set, or unmount it while the settings page is displayed.

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.

Done. Both detail wrappers carry inert={settingsTarget !== null || undefined}, alongside the existing inert={catalogCovered || undefined} on the catalog sibling.

Comment on lines +499 to +503
// Only remember the revision when a fetch really started; the in-flight
// guard can refuse, and recording it anyway skips that revision for good.
if (requestDetail(selectedId_)) {
lastFetchedRef.current = revision;
}

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 Retry a detail revision rejected by the in-flight guard

When a selected request already has cached details and a newer list revision arrives while its previous detail fetch is still in flight, requestDetail returns false and this effect records no retry. The eventual fetch completion changes details but leaves selectedIsMissing false, so none of the effect dependencies change; if that newer revision is the terminal one, no further fetch starts and the full final reply remains stale or truncated indefinitely. Track the rejected revision and retry after the in-flight request settles.

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.

Done. The effect tracks the last revision a fetch actually started for, and when requestDetail refuses it schedules a 250 ms nudge through retryTick rather than recording the revision. Nothing else in the dep list moves when the in-flight fetch settles, which is the gap described.

Comment on lines +369 to +370
if payload.remove is not None:
is_removal = payload.remove

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 Make explicit removal override supplied config fields

For a direct API request that sends remove: true together with any config field, is_removal only disables launch-flag carry-over; all supplied fields are still passed to set_model_override, so the entry is updated rather than removed. Since remove was added as the explicit operation discriminator, callers reasonably expect it to clear the entry regardless of stale form fields in the payload. Short-circuit to an empty override when payload.remove is true.

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.

Done, and it landed before this review. payload.remove is True short-circuits to set_model_override(target_id, llama_extra_args = [], max_seq_length = None) and never reaches the branch that passes the config fields. Only the explicit flag short-circuits; the legacy inferred path still just gates launch-flag carry-over, so the existing carry-over test stays green.

It also removes under resolve_model_override_key(payload.model_id) or payload.model_id, since the browser normalizes casing before storing and a literal-key removal would leave the stored entry behind.

- Never case-fold a filesystem path when looking up an override. Two POSIX
  paths differing only in case are two different files, so a near miss must
  load defaults rather than replay another model's context and GPU pin. Repo
  ids still fold, which is what the migration needs.
- Match a quant suffix against the loader's own quant pattern instead of a
  length heuristic. "/models/foo:bar.gguf" is one valid POSIX filename, and
  splitting it grafted /models/foo's launch flags onto an unrelated model.
- Retry a load once without the saved gpu_ids when the loader rejects the pin.
  The pre-flight check cannot mirror every rule the loader applies (a Vulkan
  diffusion GGUF refuses GPU selection outright, and the rules move), so this
  stops chasing them one at a time: a stale placement preference must never be
  the reason a request cannot be served.
- Make an explicit remove win over config fields sent in the same payload.
- Seed only finished requests on the monitor's first snapshot. A request still
  running when Studio loads is traffic the user has not seen, not history.
- Nudge the detail effect when the in-flight guard refuses a fetch. Nothing
  else changes its deps when the older fetch settles, so a terminal reply could
  stay truncated forever.
- Invalidate pending row lookups when opening settings from a detail card, not
  just from a row.
- Mark the covered detail pane inert so it leaves the focus order.
- Refuse to open settings for a variant-required GGUF whose quant could not be
  resolved: the picker matches variants exactly and would never find the saved
  config, while the API falls back to the bare key and would apply it.
- Mirror to the server only for GGUFs. The auto-switch resolver indexes GGUFs,
  so a safetensors config was being advertised as applied on API load when no
  API request could ever apply it.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Fourth round: all ten correct. Also confirming the previous CI fix landed.

CI verdict on the Chat UI failure. Per-SHA breakdown: 8f322aa had 3 failures (the three Chat UI Tests runners), 0a49dfd with the API-key gating has 15 green and 0 failures. So scoping auto-open to sk-unsloth callers fixed it, and the panel no longer appears during ordinary chat.

Case folding on filesystem paths. Correct, and it was a hole I opened last round. /models/foo.gguf and /models/Foo.gguf are two different files on Linux, and with only one entry present my fallback would apply it to the other, replaying a different model's context, chat template and GPU pin. My earlier test only covered the case where both entries exist, which is exactly why it passed. Folding is now restricted to repo-style ids, which is all the migration needs.

POSIX colons parsed as quants. Correct. /models/foo:bar.gguf is one valid filename, and my length-and-separator heuristic treated bar.gguf as a quant, then grafted /models/foo's launch flags onto an unrelated model. Now matched against the loader's own _GGUF_KNOWN_QUANT_RE rather than a heuristic of mine.

Vulkan diffusion GPU pins. Correct, and this is the second time my pre-flight check missed a loader rule. Rather than adding a third mirror that will also drift, I changed the approach: the load now retries once without the pin when the loader rejects it with a GPU-related 400. The principle is that a stale placement preference must never be the reason a request cannot be served, and that holds for rules I have not seen. A non-GPU failure is still raised untouched.

Explicit remove with config fields. Correct. Worth noting I initially over-applied this: short-circuiting on the inferred removal path too broke an existing test, because that path only ever gated launch-flag carry-over and still wrote the payload. Scoped to remove is True only, which is what you actually asked for.

Traffic live at first paint. Correct. The first snapshot now seeds only finished requests; a request still running when Studio loads is traffic the user has not seen.

Detail revision refused by the in-flight guard. Correct, and my previous fix was half of one. Recording only on success stopped the wrong revision being marked fetched, but nothing re-triggered the effect when the older fetch settled. It now nudges itself.

Settings sequence, inert detail pane, required-variant fallback, non-GGUF rows. All correct. The last one is the most substantive: resolve_local_gguf indexes GGUFs, so a safetensors config was being advertised on the monitor's "applied on API load" list when no API request could ever apply it. The server mirror and the backfill are now GGUF-only, and the local per-model config still works for picker loads as before.

226 backend tests, 61 in the frontend sandbox, 188 in the Python sandbox. tsc, build, ruff and the kwarg hook clean. I also reverted a batch of unrelated formatting churn that biome kept reintroducing into model-config-page.tsx, so the diff there is one functional change.

@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: 9c722b9059

ℹ️ 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".

// safetensors config to the server would advertise settings on the monitor's
// "applied on API load" list that no API request can ever apply.
if (!saveFailed && target.isGguf) {
syncModelOverride(

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 Remove server overrides evicted from local storage

When saving a new configuration pushes the local map over its 500-entry or 1 MiB budget, savePerModelConfig silently evicts older model entries and still returns success, but this call mirrors only the model currently being saved. The evicted models therefore retain server-side overrides that the UI no longer shows and cannot forget, so later API auto-switches continue applying stale settings. Track the keys evicted by enforceStorageBudget and remove their corresponding server overrides as part of the synchronization.

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.

Already handled. savePerModelConfig takes an evicted array and model-config-page.tsx clears each dropped model server side right after the mirror:

for (const dropped of evicted) {
  syncModelOverride(dropped.modelId, dropped.ggufVariant, null);
}

There was no test holding it in place, which is a fair catch. Added test_evicted_local_configs_drop_their_server_overrides in 6ad8cb6, which also pins that the eviction path decodes the storage key back into a model id and variant rather than reporting the normalized key.

Comment on lines +209 to +213
// The backlog built up while the poll was stood down is not new traffic.
useEffect(() => {
if (onFullPage) {
seededRef.current = false;
}

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 Seed known traffic after resuming overlay polling

When the user leaves the full monitor while an API request is still running, this resets seededRef, but the next snapshot seeds only terminal entries and consequently treats that already-observed running request as new, reopening the floating panel immediately. A similar stale-baseline problem occurs after auto-open is disabled and later re-enabled because polling stops without resetting the baseline, so completed traffic accumulated while disabled can trigger the panel. Track whether polling is resuming from a deliberate hiatus and seed all IDs from its first snapshot rather than applying the fresh-page running-request rule.

Useful? React with 👍 / 👎.

Saving a config can push the browser's map over its entry or byte budget, at
which point older models are silently dropped and the save still reports
success. Their server-side overrides survived, so API loads kept applying
settings that nothing in the UI showed any more and nothing could forget.

savePerModelConfig now reports what it evicted, and the caller clears those
server entries alongside the one it saved.

Removal also had to resolve the way lookup does. Storage keys are normalized,
so an evicted entry comes back lowercased while the server may hold the repo's
real casing; deleting only the literal key would leave the entry a load still
resolves to. Read and remove now share resolve_model_override_key, so what a
load applies and what forgetting clears cannot disagree. Path ids still match
exactly, so one file is never forgotten by clearing its case-variant neighbour.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Fifth round: one comment, correct.

Server overrides for models evicted from local storage. Right, and it is the same divergence class as the earlier ones but reached by a path I had not considered. savePerModelConfig silently evicts older entries to stay inside the 500-entry / 1 MiB budget and still returns success, so saving one model could drop others. Their server entries survived, which meant API loads kept applying settings nothing in the UI showed and nothing could forget, since the monitor's list is deliberately read only.

savePerModelConfig now reports what it evicted and the caller clears those server entries alongside the one it saved.

Chasing that surfaced a second half to it. Storage keys are normalized, so an evicted entry comes back as q4_k_m while the server may hold Q4_K_M; deleting the literal key would have left the entry a load still resolves to. Read and remove now share resolve_model_override_key, so "what a load applies" and "what forgetting clears" cannot disagree. Path ids still match exactly, so /models/foo.gguf is never forgotten by clearing /models/Foo.gguf.

Four new sandbox tests drive the real storage module past its cap and assert that everything reported as evicted is genuinely gone locally (clearing a server entry the UI still shows would be worse than the bug), that the model being saved is never reported, and that keys decode back to an id and quant. Two backend tests cover the casing-aware removal and the path exception.

228 backend tests, 65 in the frontend sandbox, 188 in the Python sandbox. tsc, build, ruff and the kwarg hook clean.

CI status. Per SHA on this branch:

SHA Result
8f322aa 9 success, 3 failures (Chat UI Tests on Linux, Mac and Windows)
0a49dfd 15 success, 0 failures
9c722b9 queued when this push superseded it

So the API-key gating fixed the Chat UI failure, confirmed rather than assumed. Nothing in CI needs fixing right now.

One thing I should flag rather than leave implicit: rounds four and five have not had their own CI verdict, because each push cancels the previous SHA's queued jobs and the runners have been slow. Round five contains round four, so the pending run on baf11a0 covers both, but until it finishes those two rounds are verified locally only.

@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: baf11a01f7

ℹ️ 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 +49 to +54
const local = listPerModelConfigs().filter(
// A quant means it is a GGUF, which is the only thing API auto-switch
// resolves. Backfilling a safetensors config would claim an API behaviour
// that does not exist.
(entry) => entry.ggufVariant != null && !isDefaultConfig(entry.config),
);

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 Backfill variantless standalone GGUF settings

When upgrading with saved settings for a standalone .gguf file, its config legitimately has ggufVariant === null: resolve_local_gguf represents standalone files with an empty variant list and auto-switches them using the bare model key. This filter incorrectly treats every variantless entry as non-GGUF, then marks the migration complete if no other entries remain, so those existing settings never reach the server and API-triggered loads permanently use defaults. Fresh evidence beyond the earlier migration comments is the resolver's explicit variantless standalone-GGUF path; classify entries by model format/path rather than requiring a quant.

Useful? React with 👍 / 👎.

Resolves an import conflict in hub-page.tsx: main added useInferenceGpuInfo
alongside useGpuInfo while this branch added the toast import. Both are kept.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

No new review findings this round. The latest Codex review is against baf11a01, and the newest inline comment is still the storage-eviction one from 06:25 that the previous push fixed. The counts GitHub shows against baf11a01 are older comments being re-anchored to the new head, not new findings.

CI did need fixing, though it was not a test failure. pre-commit.ci - pr was reporting error during mergeable check. The cause was that this branch had drifted 17 commits behind main and had gone into conflict, so there was nothing for pre-commit.ci to check out.

Merged main in. One conflict, in hub-page.tsx: main added useInferenceGpuInfo next to useGpuInfo while this branch added the toast import on the adjacent line. Both sides are additive, so both are kept.

The PR reports MERGEABLE again where it was CONFLICTING, and pre-commit.ci is running rather than erroring.

Re-verified on the merged tree rather than assuming the merge was inert: 232 backend tests (up from 228, since main brought its own), 65 frontend sandbox, 188 Python sandbox, plus tsc, the production build, ruff and the kwarg hook. All clean.

Worth noting for anyone reading the check list: the three UI CI failures still visible on 8f322aa are from an older SHA and were fixed on 0a49dfd (15 green, 0 failures). Rounds four and five have not had a completed CI run of their own, because each push cancels the previous SHA's queued jobs and the runners have been slow; the run now queued on the merge commit is the first that covers everything.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

@codex review

@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: a25af7662a

ℹ️ 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 +550 to +552
if not payload.fill_absent_fields:
for alias_id in cached_repo_alias_keys(target_id):
set_model_override(alias_id, llama_extra_args = [], max_seq_length = None)

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 Reconcile cached-repo aliases during backfill

When an upgraded browser still has a snapshot-path config but the server already holds a newer repo-ID config for the same cached quant, the one-time fill_absent_fields backfill creates the path entry and this condition deliberately leaves the repo alias in place. The auto-switch lookup checks the concrete snapshot path first, so the stale browser config then shadows the newer server config on every API load. Fill into the existing alias, or otherwise prevent a fill from creating a higher-priority duplicate.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, fixed in ea63c8b. resolve_model_override_key cannot fold a path onto a repo id, so the fill wrote a brand new snapshot-path key at higher priority and the alias retirement was deliberately skipped. That contradicts the invariant this code already asserts twice, in test_fill_absent_fields_carries_the_browser_only_settings_into_a_legacy_entry and test_fill_absent_fields_put_never_replaces_a_newer_server_value.

The redirect is direction-aware on purpose: only the path spelling outranks, so a repo:QUANT fill over an existing path entry still creates its own key and test_the_one_time_fill_retires_nothing stays green, and two snapshot paths never redirect since neither is knowably the load path. test_a_fill_never_creates_a_snapshot_path_key_over_a_repo_id_entry fails on the unfixed source.

Comment on lines +409 to +411
(hydratingExistingModel ||
(prevState.loadedChatTemplateOverride === null &&
prevState.chatTemplateOverride === null)) && {

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 Adopt chat-template changes from same-model reloads

When another tab or API caller reloads the same checkpoint and quant with a different chat-template override, hydratingExistingModel is false and both existing template fields are normally non-null, so the refreshed status is ignored. The Hub consequently continues presenting the old template as the live configuration, and applying another edit can persist and reload that stale template over the new one. Preserve a genuinely dirty control, but advance both fields when the control still equals its previous baseline.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, fixed in ea63c8b. Worth noting the path is narrower than described: it does not need a second Studio tab. adoptResidentModelStatus calls applyStatus even when the checkpoint already matched, the Hub drives it on tab focus and again immediately before opening the settings editor, and an auto-switch load applies the saved per-model template, so one Hub tab plus one API client is enough.

The mid-edit protection is kept. The branch fires only when the incoming template differs, so an ordinary steady poll still writes nothing. The baseline always advances, since it is a fact about the running server, but the control advances only while it still equals the old baseline, so a dirty or deliberately blanked control is left alone. Blank and null compare equal, matching cleanTemplate and the backend normalisation. Five of the nine new tests fail on the verbatim pre-fix guard.

Comment on lines +467 to +471
if requested_extra_args is None:
# First per-quant save for flags under the bare repo id; carry them over.
bare_id = _bare_model_id(payload.model_id)
if bare_id:
requested_extra_args = get_model_override(bare_id).get("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 Carry legacy standalone GGUF flags into bare-path saves

When an upgraded standalone GGUF still has its override under the old <path>:<filename-derived-label> key, the current UI now saves settings under the bare path. This carry-over checks only the bare-repo direction and cached-repo aliases, so it does not recover llama_extra_args from that legacy qualified key; the new bare entry then wins the loader's lookup and silently disables launch flags the settings UI cannot display or restore. Consult the derived legacy standalone key before writing the bare entry, as the removal path already does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, fixed in ea63c8b. The loader reads the bare path at position 3 and <path>:LABEL at position 4, and for a colon-free path neither _bare_model_id nor cached_repo_alias_keys fires, so nothing consulted the derived key. The settings page never sends llama_extra_args on a save, so the backend carry-over was the only thing preserving them. The save branch now calls _legacy_standalone_gguf_key, as the removal branch already did. test_a_standalone_gguf_save_keeps_its_filename_label_launch_flags fails with KeyError: llama_extra_args on the unfixed source.

I did not also retire the legacy entry after the save. Retiring deletes user data, the loader outcome is identical once the bare entry exists, and an explicit forget already clears it.

Comment on lines +436 to +445
for source, target in (
("llama_extra_args", "llama_extra_args"),
("kv_cache_dtype", "cache_type_kv"),
("speculative_type", "speculative_type"),
("spec_draft_n_max", "spec_draft_n_max"),
("tensor_parallel", "tensor_parallel"),
("chat_template_override", "chat_template_override"),
):
if override.get(source) is not None:
kwargs[target] = override[source]

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 Strip carried flags that shadow saved controls

When a legacy or API-authored override contains pass-through flags such as --ctx-size, cache-type, speculative, split-mode, or chat-template flags, a later settings-page save preserves those hidden llama_extra_args while also storing the corresponding first-class control. This mapper sends both as an explicit load request; core/inference/llama_server_args.py documents that explicit extras are appended after Unsloth's flags and win, so an old --ctx-size 8192 silently overrides a newly saved 32768 context on every API load. Treat these carried flags like inherited extras and strip each shadowing group when the override supplies its first-class field.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, fixed in ea63c8b. Confirmed the mechanism end to end: llama_cpp.py:8773 appends user pass-through args last, after the first-class context at :8392, and resolve_requested_ctx at :7235 also adopts the extras value for the VRAM budget, so a stale flag won twice.

Rather than add a parallel stripper I import strip_shadowing_flags, the one _resolve_inherited_extra_args already uses, so the two paths cannot drift over which flag belongs to which group. Each group is gated on the first-class key the override actually supplies, mirroring the route gating on model_fields_set, so a flag with nothing to shadow still passes through. test_a_carried_ctx_flag_cannot_outrank_a_freshly_saved_context runs through the real PUT route and the auto-switch hook, and fails on the unfixed source with the load still carrying --ctx-size 8192.

danielhanchen and others added 2 commits July 30, 2026 13:47
…load reads

Four defects, all one shape: a write leaves a key the loader reads before, or
instead of, the key holding the values the user actually meant.

Standalone GGUF saves now consult the filename-derived legacy key. The loader
reads the bare path ahead of "<path>:LABEL", and the settings page never sends
llama_extra_args, so the backend carry-over was the only thing preserving those
flags; for a colon-free path neither _bare_model_id nor cached_repo_alias_keys
fires, so the new bare entry won and the flags became unreachable. The removal
branch already called _legacy_standalone_gguf_key; the save branch now does too.

The one-time fill no longer creates a snapshot-path key over a repo-id entry.
resolve_model_override_key cannot fold a path onto a repo id, so a backfill from
a pre-upgrade browser wrote a brand-new higher-priority key and skipped alias
retirement, shadowing newer server values on every API load. The redirect is
direction-aware: only the path spelling outranks, so a repo-id fill over an
existing path entry still creates its own key, and two snapshot paths never
redirect since neither is knowably the load path.

Carried pass-through flags no longer shadow first-class controls. A save keeps
stored llama_extra_args while writing the field just edited, and the auto-switch
mapper sent both, so a stale "--ctx-size 8192" landed after Unsloth's own flags
and won llama.cpp's last-wins parse against a freshly saved 32768. The /load
route already strips exactly these groups off inherited extras; that stripper is
imported rather than mirrored, gated per group on the field the override
supplies, so a flag with no first-class field behind it still passes through.

A same-model reload now advances the chat template. hydratingExistingModel is
computed from checkpoint and quant alone, so a reload from another client left
the Hub presenting the old template as live, and the next Apply persisted and
reloaded it over the new one. The baseline always advances; the control advances
only while it still equals the old baseline, so a mid-edit value survives. Blank
and null compare equal, matching cleanTemplate and the backend normalisation.
The resolver is a separate module because the applier imports the model-picker
barrel, which is unimportable under node --experimental-strip-types.

Each fix was checked against its own test on the unfixed source first: 2 fail
for the settings-key pair, 2 for the shadowing flags, 5 for the template seed.
326 backend, 316 llama-server/chat-load, 103 contract and 127 node tests pass,
with typecheck, build and ruff clean.
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@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: b19c206f34

ℹ️ 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 +126 to +128
// Opted out and closed: nothing to open or show, so polling is pure load.
if (onFullPage || (!autoOpen && !isOpen)) {
return;

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 Re-seed the traffic watch after an auto-open opt-out

When the floating monitor is disabled while closed, this early return stops polling but leaves watchRef.current.seeded and its old seenIds intact. If API requests occur during the opt-out and the user re-enables automatic opening later, the first poll treats every retained request ID as fresh traffic and can immediately reopen the panel even though no new request arrived. Re-arm the watch when polling stands down for autoOpen === false so the first snapshot after re-enabling is treated as backlog.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 14fa776, though the stated mechanism does not hold and the one-line remedy is not sufficient. Both are worth recording.

Retained ids do not read as fresh. With seeded still true the seeding block is skipped and the snapshot is diffed against the retained set, and seenIds is re-seeded to the whole snapshot on every poll, so it stays bounded and holds the complete snapshot as of the stand-down. Driving the module directly with an unchanged entry list after an opt-out returns false.

What is real is narrower: calls landing during the opt-out are unseen ids, so the first fresh snapshot after re-enabling pops the panel for an hour-old burst, and the suppressed guard cannot stop it because lastNewEntryAt is frozen for the whole opt-out so quietFor always exceeds REARM_QUIET_MS. That contradicts the invariant stated at api-monitor-overlay.tsx:196-197, which the other stand-down already enforces.

Re-arming at stand-down alone still pops, because autoOpen is an observer dependency: the re-enable render folds the stale pre-opt-out snapshot first and spends the re-arm before any fresh poll resolves. So observeResponse now folds each snapshot at most once, and standDownWatch re-arms only a watch that has seeded, leaving first-snapshot semantics intact for a session that merely starts opted out. Two of the five new tests fail on the unfixed source, and one of those two also fails on the naive remedy.

Comment on lines +508 to +514
const status = await getInferenceStatus();
const checkpoint = resolveInferenceCheckpointId(status);
if (!checkpoint) {
setUnloadError(null);
return;
}
await unloadModel({ model_path: checkpoint });

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 Retry when the active model changes before unload

When an API auto-switch replaces model A with model B after this status read but before the unload request acquires the backend lifecycle gate, the request still names A. The unload route deliberately treats such stale model IDs as successful no-ops, so this button reports success and refreshes while B remains loaded and continues occupying VRAM. Recheck the resident checkpoint after the unload or use an atomic unload-current operation so a concurrent switch cannot make the action silently miss its target.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, fixed in 14fa776. Verified each link: model_path is required (models/inference.py:220); a replaced id skips the GGUF branch yet still reaches backend.unload_model, which returns True for a name it never loaded (orchestrator.py:1386-1391), so the route returns status="unloaded" at inference.py:7161; /unload takes inference_lifecycle_gate() at inference.py:7097, the same gate the auto-switch holds across its whole swap at :4556; and GET /status takes no gate while the switch does substantial awaited work before teardown, so the window is wide rather than notional.

There is no unload-current route, so a recheck was the smaller fix rather than a new atomic operation. unloadResident reads, unloads and re-reads with one bounded retry, and returns the model still resident instead of swallowing it, so the button now surfaces it. Steady-state cost is one extra GET /status per click and never an extra unload.

Separately, test_monitor_unload_clears_only_the_model_it_freed asserted a verbatim source line, so it passed on the racy single-pass version and broke only when the text moved. It never covered its own name. The race is now pinned behaviourally in studio/frontend/tests/api-monitor-unload-resident.test.ts, where 3 of 5 cases fail on the unfixed logic.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Jul 30, 2026
Two defects on the new monitor, both where this PR's own feature is the
concurrent actor: an API auto-switch runs while the page is watching it.

Turning automatic opening off no longer arms a delayed pop. The poll stood down
without re-arming the watch, so API-key calls landing during the opt-out stayed
unseen and the first snapshot after re-enabling reopened the panel for an
hour-old burst. The suppression guard could not stop it either, since
lastNewEntryAt is frozen for the whole opt-out and quietFor always exceeds
REARM_QUIET_MS. That contradicts the invariant stated a few lines above it, that
a backlog built while the poll stood down is not new traffic, which the other
stand-down already enforces.

Re-arming alone is not enough: autoOpen is an observer dependency, so the
re-enable render folds the stale pre-opt-out snapshot first and spends the
re-arm before any fresh poll resolves. observeResponse now folds each snapshot
at most once, and standDownWatch re-arms only a watch that has seeded, so a
session that merely starts opted out keeps its first-snapshot semantics.

Unload no longer reports success over a model it did not free. The button named
the checkpoint it read from /status, but /unload takes the lifecycle gate the
auto-switch holds across a whole swap, and the switch does substantial awaited
work before teardown, so status answers with the outgoing model for a wide
window. Naming a model a load has replaced is a 200 no-op, so the button
reported success while the new model kept its VRAM. There is no unload-current
route, so unloadResident reads, unloads and re-reads, retrying once and
surfacing the model still resident instead of swallowing it. Steady-state cost
is one extra GET /status per click and never an extra unload.

Note on the contract test this moved. test_monitor_unload_clears_only_the_model
_it_freed asserted a verbatim source line, so it passed on the racy single-pass
unload and broke only when the text moved; it never covered its own name. The
assertion now matches the new shape and its docstring points at the node test
that exercises the race.

Each fix failed its own test on the unfixed source first: 2 for the re-arm, of
which one pins that the naive one-line remedy is still broken, and 3 for the
unload. 2142 tests/studio, 326 backend, 137 node tests pass, with typecheck and
build clean.
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Jul 30, 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: 14fa776e82

ℹ️ 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".

const seq = ++residentStatusSeq.current;
return Promise.all([getInferenceStatus(), readIdleUnloadArmed()])
.then(([status, idleUnloadArmed]) => {
if (seq !== residentStatusSeq.current) return;

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 Wait for the superseding status refresh

When a settings-opening call awaits this refresh and a later focus or visibility refresh starts before its response arrives, this branch discards the first response but still resolves its promise. The settings handler then proceeds using the stale runtime store while the superseding request remains in flight, so on a slow backend the editor can again be seeded from the previously resident model and Apply can persist those settings under the wrong target. Coalesce concurrent refreshes or make an obsolete caller await the latest refresh before resolving.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct on the mechanism, fixed in 0da9cc8, though half the stated consequence does not occur.

The guard gap is real. hub-page.tsx:417 is a plain generation check that writes nothing and still resolves, and settingsOpenSeq is bumped only at :1341 and :1482, i.e. only by opening settings, so a focus or visibility refresh supersedes a settings-open read while passing settingsOpenSeq.current !== openSeq. Sequence numbers are monotonic with start order, so every other superseder either is covered by that guard or starts after the awaited read and therefore wins. Focus and visibility are the only uncoordinated ones.

The editor cannot keep the old config, though. loadedConfigSignature is folded into the React key at catalog/hub-model-settings-view.tsx:124-132, so the view remounts and re-seeds when the live config lands, and hub-page.tsx:1329-1331 fires a fresh refresh the moment settingsTarget is set, which always starts last. What does not self-heal is the quant baked into settingsTarget by the stale read at :1373-1396 and :1486-1506, which is what runSettingsTarget passes to selectModel and what settings are persisted under. That residual is the reason I took it.

A dropped response now resolves with the refresh that superseded it. Only a strictly newer sequence hands back a promise, so the unmount cleanup at :476, which bumps the sequence without starting a read, cannot make the last refresh await itself and hang. Two of the six new tests fail on the unfixed semantics.

Comment on lines +190 to +192
const now = Date.now();
const quietFor = now - lastNewEntryAtRef.current;
lastNewEntryAtRef.current = now;

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 Use a monotonic clock for the re-arm timeout

When the system clock moves backward after the user dismisses the panel, quietFor becomes negative and subsequent API traffic remains suppressed until wall time catches back up, which can be far longer than the intended 60-second quiet period after a manual clock correction or VM clock adjustment. This duration should use the same monotonic performance.now() clock already used by the traffic watch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not taking this one, because the stated harm does not follow from the code.

lastNewEntryAtRef is re-anchored unconditionally at api-monitor-overlay.tsx:192, before both early returns at :193 and :197, so quietFor always measures the gap between two consecutive new-traffic observations rather than the time since dismissal. A backward clock step therefore corrupts exactly one comparison, the one straddling the jump; the next new-traffic event compares two post-jump timestamps and re-arms after a real 60 seconds. Traffic does not stay suppressed until wall time catches back up. The description would hold only if the ref were stamped at dismissal and left alone, which it is not.

That single corrupted comparison leaves the panel closed, which is the safe direction here: the overlay popping over the chat composer is the regression this suppression exists to prevent. For completeness, the direction you did not raise is the harmful one, since a forward jump inflates quietFor and opens the panel early, but it is likewise capped at one event.

You are right that the clocks are mixed: Date.now() at :190 against performance.now() at :143 and :185, and new-traffic.ts:26 documents the monotonic choice deliberately. That is worth tidying, but at one skipped auto-open behind a manual clock or VM adjustment it is not a P2 defect on this PR, so I am leaving it rather than reshuffling this file again mid-review.

Comment on lines +277 to +280
const clear = useCallback(async (): Promise<void> => {
await clearApiMonitor();
setDetails({});
await load();

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 Surface failures when clearing the monitor

When the DELETE request fails because the server is temporarily unreachable, authentication expires, or the backend returns an error, this rejection escapes clear() and the click handler intentionally discards the promise. The page clears the selected row but leaves the log unchanged without showing an error, while also producing an unhandled promise rejection; catch the failure and route it through the monitor's existing error state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, fixed in 0da9cc8. clearApiMonitor rejects both on a network failure and on any non-ok DELETE via parseJsonOrThrow at chat-api.ts:95, clear() had no catch, and the handler at api-monitor-page.tsx:700-702 discards it with void, so setSelectedId(null) emptied the detail pane while the log stayed put with no message. Routed through the hooks existing errorstate atuse-api-monitor.ts:186, which the banner at api-monitor-page.tsx:789-793already renders, and the reload is skipped since nothing was deleted. The page is untouched: onceclear()stops rejecting,void clear()` is correct.

One limit worth recording. When the DELETE alone fails while polling still succeeds, the polls success branch calls setError(null)` within about a second, so the message flashes rather than persists. That is inherent to reusing the shared error state as suggested, and still better than silence plus an unhandled rejection. The case it fixes outright is a paused monitor: the poll returns early when paused while the button is disabled only on an empty list, so that failure was permanent and silent. Two of the three new tests fail on the unfixed source.

… a refused clear

A superseded status read no longer releases the caller awaiting it. The drop
branch was a plain generation check that wrote nothing and still resolved, and
settingsOpenSeq is bumped only by opening settings, so a focus or visibility
refresh could supersede a settings-open read while passing that guard. Every
other superseder is either covered by settingsOpenSeq or starts after the
awaited read and therefore wins; focus and visibility are the only uncoordinated
ones.

Half of the reported harm does not occur: the editor cannot keep the old model's
config, because loadedConfigSignature is folded into the React key so the view
remounts when the live config lands, and setting settingsTarget fires a fresh
refresh that always starts last. What does not self-heal is the quant baked into
settingsTarget by the stale read, which is what runSettingsTarget passes to
selectModel and what settings are then persisted under. That is the harm
test_detail_settings_defers_a_derived_quant_to_a_fresh_status_read already
guards elsewhere, so a dropped response now resolves with the refresh that
superseded it. Only a strictly newer sequence hands back a promise, so the
unmount cleanup, which bumps the sequence without starting a read, cannot make
the last refresh await itself.

A refused clear no longer fails silently. clearApiMonitor rejects on a network
error and on any non-ok DELETE, clear() had no catch, and the page discards the
promise with void, so the detail pane emptied while the log stayed put with no
message and an unhandled rejection behind it. The hook already owns an error
state the banner renders, so the failure routes through it and the reload is
skipped, since nothing was deleted.

One limit worth stating: when the DELETE alone fails while polling still
succeeds, the next poll clears the shared banner within about a second, so the
message flashes. That is inherent to reusing the existing error state, and it
still beats silence. The case it genuinely fixes is a paused monitor, where the
poll returns early while the button stays enabled, so the failure was permanent.

Each fix failed its own test on the unfixed source first: 2 of 6 for the
supersession, 2 of 3 for the clear. 2142 tests/studio and 146 node tests pass,
typecheck and build clean, and no contract assertion needed changing.
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@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: 0da9cc816c

ℹ️ 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 +591 to +593
if not payload.fill_absent_fields:
for alias_id in cached_repo_alias_keys(target_id):
set_model_override(alias_id, llama_extra_args = [], max_seq_length = None)

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 Make alias retirement atomic with the override save

When two tabs or API clients concurrently save the same cached quant using its repo ID and legacy snapshot-path alias, each request writes its target before independently finding and deleting aliases here. An interleaving where both writes complete before either cleanup lets the repo-ID request delete the path write and the path request then delete the repo write, leaving no override despite both saves succeeding. Collapse the write and alias retirement into one database transaction or serialize operations by the shared cached-repo identity.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, fixed in ec50d4e, and reproduced rather than reasoned about.

I first checked whether the sequence was atomic under the event loop, which would have made it unreachable. It is not: the handler is a plain def at settings.py:467, so FastAPI runs it via run_in_threadpool and the two requests genuinely interleave. Each individual write is atomic, since set_model_override goes through upsert_app_setting_map_entry under BEGIN IMMEDIATE at studio_db.py:3069, but nothing spans calls: the target write, cached_repo_alias_keys and the alias delete are three separate transactions, and the only lock nearby guards the 2s memo. With both writes landing before either cleanup the store ends empty while both calls return 200, which is exactly your stated end state.

The two spellings are reachable. The shipped UI keys a cached repo by repo id, but the browsers write queue is keyed by spelling (model-overrides.ts:160-167) so the two go out in independent queues rather than serialized, and the eviction mirror at model-config-page.tsx:1069sendsdropped.modelIdstraight from localStorage, which can still be a legacy snapshot-path key because theopenModelSettingsbranch athub-page.tsx:1433never adopts it, unlike:1300and:1540`. It is also a documented REST endpoint.

The server is a single uvicorn process with no workers configured (run.py:1927-1941), so a module-level lock is a complete fix. It is applied as a decorator rather than a with block so the handler body is not reindented, which keeps the verbatim-source contract assertions intact, and so the remove branchs four-write sequence is covered by the same lock. The new test pins both threads just after their target write with a barrier, falling back on a broken barrier once they serialize so the fixed path cannot deadlock, and locks the fake store per entry to mirror BEGIN IMMEDIATE` so only the inter-call gap is on trial.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Jul 30, 2026
danielhanchen and others added 2 commits July 30, 2026 15:44
The override save handler is a plain def, so FastAPI runs it in a threadpool and
two requests really do interleave. Each individual write is atomic, since
set_model_override goes through upsert_app_setting_map_entry under BEGIN
IMMEDIATE, but nothing spans calls: the target write, the alias read and the
alias delete are three separate transactions, and the only lock in either module
guards the two second memo. So when one client saves a cached quant by repo id
while another saves it by snapshot path, and both writes land before either
cleanup, the repo-id request deletes the path row and the path request then
deletes the repo row. Both calls return 200 and the store ends empty.

The two spellings are not hypothetical. The shipped UI keys a cached repo by
repo id, but the path spelling is why cached_repo_alias_keys exists at all, the
browser's write queue is keyed by spelling so the two go out in independent
queues rather than serialized, and the eviction mirror sends dropped.modelId
straight from localStorage, which can still be a legacy snapshot-path key
because the openModelSettings branch never adopts it. It is also a documented
REST endpoint any client can call.

The server is a single uvicorn process with no workers configured, so a module
level lock is a complete fix. It is applied as a decorator rather than a with
block for two reasons: the handler body is not reindented, which keeps the
verbatim-source contract assertions intact, and the remove branch's four-write
sequence is covered by the same lock.

The new test pins both threads just after their target write with a barrier,
falling back on a broken barrier once the writes serialize so the fixed path
cannot deadlock, and locks the fake store per entry to mirror BEGIN IMMEDIATE so
only the gap between calls is on trial. It fails on the unfixed source with an
empty store. 327 backend, 103 contract and 160 adjacent settings-route tests
pass, ruff clean, and no contract assertion needed changing.
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Jul 30, 2026
@danielhanchen
danielhanchen merged commit 6b0035b into main Jul 31, 2026
49 of 54 checks passed
@danielhanchen
danielhanchen deleted the studio/api-monitor-and-per-model-settings branch July 31, 2026 11:24
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Sep 21, 2026
Dropping the reported quant for the direct lookup also drops the records a
picker before unslothai#7473 wrote, which keyed the label straight through. Those are
still read on main today, so the fix as it stood blanked settings for anyone
who configured a loose .gguf before that release.

Read the bare path first and fall back to the label, which is the order the
backend already reads its own override candidates in.
danielhanchen added a commit that referenced this pull request Sep 21, 2026
* studio: restore saved settings for standalone gguf reloads

* studio: update the resident-config contract test for the standalone lookup

resolveResidentInitialConfig now drops the reported quant before the direct
lookup when the id names a standalone GGUF file, so the contract test's
literal source assertion no longer matched and the studio repo tests failed.
Assert the new call while keeping what the contract is for: the raw
identifier is still read before the cached-repo alias.

* studio: keep reading a standalone gguf record keyed by its quant label

Dropping the reported quant for the direct lookup also drops the records a
picker before #7473 wrote, which keyed the label straight through. Those are
still read on main today, so the fix as it stood blanked settings for anyone
who configured a loose .gguf before that release.

Read the bare path first and fall back to the label, which is the order the
backend already reads its own override candidates in.

* studio: tighten the comments on the standalone settings lookup

---------

Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
shaojun pushed a commit to shaojun/unsloth that referenced this pull request Sep 29, 2026
…11311)

* studio: restore saved settings for standalone gguf reloads

* studio: update the resident-config contract test for the standalone lookup

resolveResidentInitialConfig now drops the reported quant before the direct
lookup when the id names a standalone GGUF file, so the contract test's
literal source assertion no longer matched and the studio repo tests failed.
Assert the new call while keeping what the contract is for: the raw
identifier is still read before the cached-repo alias.

* studio: keep reading a standalone gguf record keyed by its quant label

Dropping the reported quant for the direct lookup also drops the records a
picker before unslothai#7473 wrote, which keyed the label straight through. Those are
still read on main today, so the fix as it stood blanked settings for anyone
who configured a loose .gguf before that release.

Read the bare path first and fall back to the label, which is the order the
backend already reads its own override candidates in.

* studio: tighten the comments on the standalone settings lookup

---------

Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
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.

2 participants