Repository navigation
studio: apply saved per-model settings on API loads, add API monitor - #7473
Conversation
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.
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 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".
| for override_key in ( | ||
| f"{override_id}:{variant}" if variant else None, | ||
| override_id, | ||
| target_id, |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 👍 / 👎.
| if requested_extra_args is None and not is_removal: | ||
| requested_extra_args = get_model_override(payload.model_id).get("llama_extra_args") |
There was a problem hiding this comment.
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 👍 / 👎.
| const baseUrl = | ||
| typeof window === "undefined" ? "" : `${window.location.origin}/v1`; |
There was a problem hiding this comment.
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 👍 / 👎.
| downloaded.find((v) => | ||
| ggufVariantsMatch(v.quant, activeGgufVariant), | ||
| )?.quant ?? |
There was a problem hiding this comment.
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 👍 / 👎.
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
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.
for more information, see https://pre-commit.ci
|
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 findingsP1, 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 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. P1, Tauri base URL. Correct. 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 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 P2, loaded-quant preference. Correct. Gated on the loaded model actually being this row, matched with the existing Found separately by fuzzingI ran this in isolated sandboxes (
Baseline check: 49 backend tests fail on this branch, and the same 49 fail on a clean 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). |
There was a problem hiding this comment.
💡 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".
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
| // 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), |
There was a problem hiding this comment.
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 👍 / 👎.
| if (!saveFailed) { | ||
| syncModelOverride( | ||
| target.id, | ||
| target.ggufVariant, | ||
| remember ? effectiveRuntimeConfig : null, | ||
| ); |
There was a problem hiding this comment.
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 👍 / 👎.
| useEffect(() => { | ||
| if (onFullPage) { | ||
| return; | ||
| } | ||
| let cancelled = false; | ||
| let timer: number | undefined; | ||
| const intervalMs = isOpen ? OPEN_POLL_MS : IDLE_POLL_MS; |
There was a problem hiding this comment.
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.
|
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. 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 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 Verification for this round: 213 backend tests pass, plus 48 in a separate frontend sandbox. Full suite is still at parity with clean 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. |
There was a problem hiding this comment.
💡 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); |
There was a problem hiding this comment.
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 👍 / 👎.
| lastFetchedRef.current = revision; | ||
| requestDetail(selectedId_); |
There was a problem hiding this comment.
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 👍 / 👎.
| const leaf = id.split(/[\\/]/).filter(Boolean).pop() ?? id; | ||
| setSettingsTarget({ |
There was a problem hiding this comment.
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 👍 / 👎.
| mode) are left to it, since their valid sets follow the llama.cpp build. | ||
| """ | ||
|
|
||
| model_id: str = Field(..., min_length = 1, max_length = 512) |
There was a problem hiding this comment.
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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| set_model_override( | ||
| payload.model_id, | ||
| llama_extra_args = extra_args, | ||
| max_seq_length = payload.max_seq_length, |
There was a problem hiding this comment.
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 👍 / 👎.
| 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: |
There was a problem hiding this comment.
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 👍 / 👎.
| // 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; | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
| useEffect(() => { | ||
| if (isAuthFlowRoute) { | ||
| return; | ||
| } | ||
| void backfillModelOverrides(); | ||
| }, [isAuthFlowRoute]); |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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 👍 / 👎.
|
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. 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. P2, detail revisions skipped during an in-flight fetch. Right, and the failure mode is exactly as described: P2, stale asynchronous settings opens. Right. P2, model_id length. Right, and the limit was mine and arbitrary. A local model's id is its filesystem path, 216 backend tests and 53 in the frontend sandbox, all passing. 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.
There was a problem hiding this comment.
💡 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".
| folded = model_id.casefold() | ||
| matches = [ | ||
| value | ||
| for key, value in overrides.items() | ||
| if isinstance(key, str) and key.casefold() == folded and isinstance(value, dict) |
There was a problem hiding this comment.
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 👍 / 👎.
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
| if (!seededRef.current) { | ||
| seededRef.current = true; | ||
| seenIdsRef.current = new Set(ids); | ||
| return; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const settingsAction = | ||
| !isDataset && onOpenSettings ? { onOpen: () => onOpenSettings(row) } : undefined; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
|
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: 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 Tests: 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 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. |
|
@codex review |
There was a problem hiding this comment.
💡 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".
| resolved = resolve_requested_gpu_ids(gpu_ids, is_vulkan = is_vulkan) | ||
| if is_vulkan and resolved: |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| } 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; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const openSelectedModelSettings = useCallback( | ||
| (ggufVariant: string | null) => { | ||
| if (!selectedModel) return; | ||
| const id = selectedModel.resource.runId; | ||
| const leaf = id.split(/[\\/]/).filter(Boolean).pop() ?? id; | ||
| setSettingsTarget({ |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| {settingsTarget && ( | ||
| <div className="hub-canvas absolute inset-0 z-30 flex min-h-0 flex-col"> |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Done. Both detail wrappers carry inert={settingsTarget !== null || undefined}, alongside the existing inert={catalogCovered || undefined} on the catalog sibling.
| // 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; | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| if payload.remove is not None: | ||
| is_removal = payload.remove |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
|
Fourth round: all ten correct. Also confirming the previous CI fix landed. CI verdict on the Chat UI failure. Per-SHA breakdown: Case folding on filesystem paths. Correct, and it was a hole I opened last round. POSIX colons parsed as quants. Correct. 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 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: 226 backend tests, 61 in the frontend sandbox, 188 in the Python sandbox. |
There was a problem hiding this comment.
💡 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( |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| // The backlog built up while the poll was stood down is not new traffic. | ||
| useEffect(() => { | ||
| if (onFullPage) { | ||
| seededRef.current = false; | ||
| } |
There was a problem hiding this comment.
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.
|
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.
Chasing that surfaced a second half to it. Storage keys are normalized, so an evicted entry comes back as 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:
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 |
There was a problem hiding this comment.
💡 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".
| 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), | ||
| ); |
There was a problem hiding this comment.
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.
|
No new review findings this round. The latest Codex review is against CI did need fixing, though it was not a test failure. Merged The PR reports Re-verified on the merged tree rather than assuming the merge was inert: 232 backend tests (up from 228, since Worth noting for anyone reading the check list: the three UI CI failures still visible on |
|
@codex review |
There was a problem hiding this comment.
💡 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".
| 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) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| (hydratingExistingModel || | ||
| (prevState.loadedChatTemplateOverride === null && | ||
| prevState.chatTemplateOverride === null)) && { |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| 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") |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| 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] |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
…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.
for more information, see https://pre-commit.ci
|
@codex review |
There was a problem hiding this comment.
💡 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".
| // Opted out and closed: nothing to open or show, so polling is pure load. | ||
| if (onFullPage || (!autoOpen && !isOpen)) { | ||
| return; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const status = await getInferenceStatus(); | ||
| const checkpoint = resolveInferenceCheckpointId(status); | ||
| if (!checkpoint) { | ||
| setUnloadError(null); | ||
| return; | ||
| } | ||
| await unloadModel({ model_path: checkpoint }); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
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.
|
@codex review |
There was a problem hiding this comment.
💡 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; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const now = Date.now(); | ||
| const quietFor = now - lastNewEntryAtRef.current; | ||
| lastNewEntryAtRef.current = now; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const clear = useCallback(async (): Promise<void> => { | ||
| await clearApiMonitor(); | ||
| setDetails({}); | ||
| await load(); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| 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) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
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.
for more information, see https://pre-commit.ci
|
@codex review |
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: 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>
…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>

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_argsandmax_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
LoadRequestthe picker builds, so a remote load and a picker load of the same model produce the same command line.normalize_model_overridevalidates 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_kwargsmaps the stored config toLoadRequestkwargs. 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_lengthmirrorsresolveFitMaxSeqLengthin the UI: under Manual GPU memory with Auto layers, llama.cpp--fitowns context sizing, so the load sends the context pin rather than the stored max sequence length.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-overridesAPI, 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-monitorwith 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 /monitoris 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
test_auto_switch_applies_model_overrideandtest_auto_switch_applies_partial_overridepass unmodified, which is the proof.test_auto_switch_falls_back_to_bare_repo_override.llama_extra_argspreserves 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.pyandtest_api_monitor.py, including 9 new ones covering field-dropping and GPU id dedupe innormalize_model_override, byte-limited chat templates,spec_draft_n_maxonly being stored for MTP modes,--fitcontext sizing, GGUF gating in the mapper, both override key resolution paths, the launch-flag carry-over contract, and subject-scopedmonitor.clear().The mapper test constructs a real
LoadRequestfrom the kwargs it produces, so a key that is not a real field fails in CI rather than as aTypeErrorthe moment a user's request arrives.tsc -bandnpm run buildare clean.Worth knowing before merging
localStorageand 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_editablefails on this branch, but it also fails on a cleanmain. Pre-existing and unrelated.