mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-16 20:33:56 +00:00
* studio: apply saved per-model settings on API loads, add API monitor 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. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: harden per-model settings against review findings and fuzzing 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. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: address second review round on per-model settings - 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. * studio: address third review round on per-model settings - 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. * studio: only open the API monitor for real API clients 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. * studio: address fourth review round on per-model settings - 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. * studio: clear server overrides for models evicted from local storage 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. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: pin the GGUF mirror gate and eviction cleanup with contract tests Both were flagged in review with no test holding them in place. Also corrects the gpu_ids preflight docstring: it claims to mirror every rule the loader applies, which it deliberately does not, and the retry below is what covers the rest. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Resolve the override key on save and match standalone gguf settings The remove branch already resolved the key a load would use, but the save branch wrote payload.model_id literally. The browser normalizes casing before storing, so a backfilled key and a later UI save left two entries for one model; with two equivalent keys present resolve_model_override_key finds no unique match, so any third casing resolved to no override at all and the model silently loaded with defaults. A standalone .gguf gets variants=() from the resolver, so variant is None and only the bare ids were tried. The picker keys the same file by the quant label it derives from the filename, which is never empty, so those settings lived under <path>:LABEL and nothing reached them. Try the filename-derived key after the variant-qualified ones and before the bare ones, so older bare entries still work. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Match server overrides by identity during the one-time backfill app_settings carries no schema version, so an install that predates identity normalization holds rows keyed by whatever id was typed, such as Unsloth/Repo-GGUF:Q4_K_M, while this browser only ever stores the folded form. The exact property lookup therefore reported "not on the server" for a row that is, and the backfill PUT over it, replacing server settings the file documents as the newer authority. The migration runs once on every existing profile, so this lands on exactly the upgrades it was written to protect. Fold both sides before comparing, splitting on the last colon because a quant label never contains one. A repo id and a Windows path fold, a POSIX path does not, which is the same rule the backend resolves by. Verified with the real module under node: the legacy-casing, variant-casing and Windows-path cases go from overwriting to skipping, a second run stays clean, and a genuinely new model, a different quant of the same repo, a POSIX path differing only in case, and a bare legacy key all still migrate. * Fold case-insensitive path ids the way the browser already does resolve_model_override_key refused the case fallback for every filesystem path, but only a POSIX path is case-sensitive. A Windows drive path, a UNC share and a WSL drive path each name one file whatever the casing, and the browser folds exactly those three before storing. A Windows user's migrated entry was therefore keyed lowercase while an API auto-switch resolved the same file with its on-disk casing, so the lookup missed and the saved launch flags silently stopped applying until the settings were saved again. Fold those three shapes here too, normalizing the separator as the browser does so C:/Models/Foo.gguf and c:\models\foo.gguf agree. POSIX stays case-sensitive, /mnt/data stays an ordinary mount rather than a WSL drive, and an ambiguous fold still matches nothing so a load takes defaults instead of guessing. The existing Windows test asserted the opposite. It carried no rationale, unlike its POSIX sibling, and get_model_override's docstring already scopes the rule to POSIX, so it read as an over-generalisation of the POSIX case. * Close clearApiMonitor The merge that brought main into this branch dropped the closing brace, so the function body ran straight into the interface declared below it and the frontend did not compile at all: tsc reports TS1005 at the end of the file and vite fails the build. Reproduced against the pushed head and clean with the brace restored. * Carry legacy flags across a scanner-derived GGUF variant label A .gguf whose filename holds no recognizable quant token still gets a label from the scanner, which falls back to the filename stem, so the UI stores keys like "/models/custom.gguf:custom". _bare_model_id accepted only known quant tokens, so the first per-quant save did not carry over llama_extra_args stored under the bare id, and auto-switch prefers the qualified entry, so those flags were silently dropped with no UI able to restore them. Accept a suffix that is exactly the label the scanner derives for that filename. Requiring the head to be a .gguf and the suffix to match exactly is what keeps an arbitrary colon-containing POSIX path out: "/models/foo:bar.gguf" splits to a head that is not a .gguf. The filename is taken by splitting on both separators, since a "C:\\..." key is written on Windows but may be read back by a backend that is not. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep model lifecycle rows out of the request statistics A load, unload or download is recorded in the monitor but is not an HTTP call. It reads as running for as long as the load takes, so it was counted as an in-flight request with no client waiting, and a multi-minute download was folded into Avg latency and the error rate. The backend already excludes these rows from active_count for the same reason, so the page was also disagreeing with the number the API itself reports. Requests counted them too, so that is now the non-lifecycle count rather than the raw entry count. Also limit the API-reach sentence on the Hub settings page to GGUF models. The Hub opens that page for every downloaded model, but ModelConfigPage mirrors settings to the server only when target.isGguf, because auto-switch indexes GGUFs only, so a safetensors user was told the settings apply to an API request that cannot reach them. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Accept bpw variants, fold POSIX quant suffixes, migrate bare GGUF configs Three gaps the previous round left, all in the same key-resolution rule, so the rule now lives in one place as split_quant_suffix. A quant label may carry a bits-per-weight modifier, because two files at the same base quant are kept distinct by it: utils/models/model_config.py preserves IQ4_XS-3.53bpw while hub/utils/gguf.py strips it, and both forms reach the override keys. The known-quant pattern accepted neither, so _bare_model_id missed the bare entry and the first qualified save dropped its launch flags. On POSIX the browser lowercases the quant but keeps the path casing, so a migrated "/models/Foo:q4_k_m" was unreachable from the scanner's "/models/Foo:Q4_K_M". Only the quant suffix folds now, and only when it really is a quant, so "/models/foo:Bar.gguf" stays a distinct filename and the path itself stays case-sensitive. A standalone .gguf picked directly has no quant to choose between and is stored with a null variant. The backfill filter read that as safetensors and skipped it, and the done flag is set on the same pass, so those settings stayed browser-only for good while auto-switch kept loading the model with defaults. * Let remove win over flag validation, and make Clear log clear shared rows An explicit remove ran the launch-flag validation first, so a form still carrying a rejected flag raised a 400 and left the override in place. Nothing is stored on that path, so there is nothing to validate; remove now short-circuits it, which is what the branch below already claims to do. Clear log dropped only the caller's own rows, but a lifecycle row is shared: it is visible to everyone and owned by no one, so those rows survived and the reload straight after the click brought them back, leaving the button visibly ineffective. Deleting them is not an option either, since that erases another caller's history. They are now hidden per subject, so the clear is true for that caller and harmless to the rest. A shared row that is still running is live state rather than history, so it stays visible, and the hidden ids are pruned against the ring buffer so they cannot accumulate. * Move the lifecycle labels out of the lazily loaded monitor page The overlay is mounted from __root.tsx and imported two label helpers from the page, so the page and its dependency graph were pulled into the eagerly loaded bundle and the route's lazyRouteComponent bought nothing: every route paid for the monitor page even when it was never opened. Measured on a production vite build, the async api-monitor chunk was 0.20 kB, meaning the implementation had landed in the main bundle. The helpers now live in their own module. The same build gives an 18.83 kB api-monitor chunk and a main bundle 18 kB smaller (3.9 kB gzipped). * Order override writes per model Saving twice quickly, or saving while the one-time backfill is still running, started independent requests with no sequencing, so the older response could commit last and resurrect the entry the newer one meant to replace or remove. An API-driven load then applies context or GPU settings the user has already changed, with nothing in the UI showing it. Writes now chain per override key. The chain hangs off the settled tail, so a failed write cannot cancel the next one, and only the last writer clears the slot so a queue that is still building keeps its order. Different models still overlap. Verified against the real module under node: two saves for one model with the first made slow commit oldest-first and never overlap, where the previous version committed them in the wrong order; a rejected write still lets the next succeed; and two models still run concurrently. * Key write queues by identity, and reach unknown GGUF labels in either casing Two holes in the previous two commits, both found by the same review round. The per-model write queue keyed on the literal spelling, so the backfill's legacy casing and a UI save's normalized one opened two queues for one model and raced exactly as before. It now keys on the folded identity, which is what the backend resolves by. A .gguf with no recognizable quant token is labelled by its filename stem, and v2 storage lowercases that label while the scanner probes with the filename's own casing. Folding only recognized quant labels therefore left the migrated entry unreachable for precisely the files that need the stem fallback. The suffix rule now also accepts a case-insensitive match against the label the scanner derives for that filename, which keeps an ordinary colon out because the head still has to be a .gguf. _bare_model_id drops onto the same shared rule rather than repeating half of it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard the backfill, the detail settings entry point and the detail retry Three separate reports, all confirmed against head. listPerModelConfigs reported future-schema records. loadPerModelConfig refuses to apply one and eviction refuses to drop one, because this client cannot interpret that schema, so handing it to the backfill would persist a partial reading of it server-side and let an API-triggered load apply settings the same client will not apply locally. It is skipped there now, matching the other two paths. The detail view's on-device card passes a null variant while its own lookup is pending or after it failed, and this entry point opened the editor anyway. That saves a bare-model config, which the picker never finds because it matches variants exactly, while the API's bare-key fallback would apply it. openModelSettings already refuses with a toast for exactly that reason; this path now refuses the same way. A failed detail fetch was never retried. The revision is recorded when the fetch starts, and on failure the entry stays missing, so selectedIsMissing does not change and a terminal row's updated_at does not advance: nothing was left to re-run the effect, and the full prompt and reply stayed unavailable until another row was selected. The in-flight flag settling is the trigger now, and the attempt count bounds it, because the usual failure is an entry that has aged out of the ring buffer and will never arrive however often it is asked for. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Point the lifecycle contracts at the module the labels moved to Extracting the labels out of the monitor page left two contracts asserting they were still in it, so the staged run went red on all three platforms. They read the new module now, and the page contract additionally pins that it imports from there rather than redefining them. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Re-read local state before backfilling, and stop advertising Ollama as API loadable The backfill wrote the snapshot it took before fetchModelOverrides resolved, so a save or a forget during that round trip was undone: the write is queued behind the interactive one and commits last, leaving the browser showing the new settings while an API load applied the old ones. Each write now re-reads the model's current local config and skips it if it has gone or gone back to defaults. Verified against the real module under node: the write carries maxSeqLength 9999 where it previously carried the stale 1000. target.isGguf was also standing in for "an API request can load this". It cannot for an Ollama model: local_model_resolver skips Ollama's scanner on purpose, so those models are never in the auto-switch index, yet the mirror ran and the settings page told the user the API would apply them. The target now carries apiLoadable, set from the inventory source the row already has, and both the mirror and that sentence read it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Key the Hub's per-model settings by the repo id, not by the load path A repo cached outside the active HF cache reports load_id as its snapshot path (cache_inventory), which is what the loader needs, but the chat picker and the auto-switch index both name that repo by repo_id. The new Hub settings page was saving under the load id, so the settings landed on a key no other load reads: the picker, an auto-load and an OpenAI-compatible request all fell back to defaults, and a server override already stored under the repo id could win against the save. ModelPickTarget now carries configId for the case where the storage identity is not the loadable one. Every read, write and server mirror in ModelConfigPage uses it; the chat template and GGUF header probes keep target.id, since they have to open the model. The Hub sets it for cache rows and resolves its own load through the same helper, so a config saved from the settings page is the one a later load finds. Rows whose load id is already their identity, which is every local row and every repo in the active cache, are unaffected. Verified against the real per-model-config module under node: saved under the snapshot path, a picker read reports remembered=false and the default max sequence length; saved under the repo id it reports remembered=true and 8192. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the comments across the API monitor and per-model settings work Pass over every comment this branch touches. Collapse the multi-paragraph rationales to the point they were making, drop prop docs that only restated the prop name, and reflow the rest onto fewer lines. No code changes. * Recognise an absolute path id in either platform spelling _is_abs_path_id decides whether an id is a host path that must not be published through /v1/models, and it asked pathlib.Path, which follows the running OS. A Windows backend therefore read "/home/me/x.gguf" as a relative name and a POSIX one read "C:\models\x.gguf" the same way, and in both cases the path was advertised verbatim as a model id. Ids outlive the machine that wrote them: settings sync, a WSL session and a copied config all carry the other platform's spelling, which is why the model-override identity in this PR already folds Windows drive, UNC and WSL paths. The backend now agrees with it, reading the value as both a POSIX and a Windows path. Neither reading can misfire on a repo id, which has no leading separator, drive letter or UNC prefix. The two tests this fixes on Windows are older than this PR; the new one pins the contract in both directions. * Give the idle-unload test a wall-clock deadline The drive loop waited a fixed 200 iterations of a 10 ms sleep. Windows rounds that sleep up to the roughly 15.6 ms scheduler tick, on this loop and on the idle loop under test alike, so the unload got far less real time there than the count suggests and the test failed on the Windows runner for being slow rather than wrong. It now waits on time.monotonic with a generous ceiling and still breaks as soon as the KV file is gone, so the fast path costs nothing. The test is older than this PR; the deadline is the only change to it. * Split a quant suffix the way the backend does, and gate the detail card too The backfill took a key's identity by splitting on its last colon, so anything else that ends in one was read as a quant separator. A Windows path made "C:\models\foo.gguf" into model "C" with variant "\models\foo.gguf", and an ordinary colon inside a POSIX filename folded "/models/foo:Bar.gguf" and "/models/foo:bar.gguf" onto one key, so whichever of the two was already on the server made the other look migrated and left its API loads on defaults. splitQuantSuffix now mirrors split_quant_suffix on the backend: the suffix has to be a known quant label, with or without a bits-per-weight modifier, or the head has to be a .gguf carrying a stem label. Checked against the backend over twelve keys, including every case above, with identical answers on both sides. Settings also opens from the on-device detail card, and that constructor never set apiLoadable, so an Ollama model reached the server mirror and the "API loads use these settings" line from that entry point even though the auto-switch resolver skips Ollama's scanner. It now reads the same source the row menu does. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Judge the config storage actually keeps, not the one on screen savePerModelConfig normalizes before deciding whether a config is default, and the runtime hands the settings page Speculative Decoding "auto", which canonicalizes to null. The page judged the raw object instead, so a model sitting at defaults looked non-default: turning on "Remember for this model" reported saved while the local write had dropped the entry, reopening showed it as not remembered, and the mirror sent the server a speculative_type "auto" override the browser did not have. That disagreement between the two is the one thing the mirror is written to avoid. The page now normalizes once and uses that object for the default check, the local write and the server mirror alike. Driving the real module under node: the raw object reads as non-default, storage stores nothing, and only the normalized reading agrees with storage. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Shorten the comments on the newest round of changes Comment-only pass over the quant-suffix split, the storage-shape normalization and the wall-clock deadline in the idle-unload test. Same points, fewer lines. * Key one config per model, and stop the backfill replacing newer server state Five follow-ups on the per-model settings map. The one-time localStorage backfill read the override map once and then wrote each model in turn, so a save by another tab during that pass was replaced by this browser's older copy, against the migration's own "never overwrites" contract. Re-fetching per model would cost a round trip each; instead the PUT takes only_if_absent and the server tests and writes under one transaction. gpu_ids arrived unbounded and normalize_model_override de-duplicated it by scanning the list it was building, so a large authenticated array cost roughly 20x what the same work costs with a set (4.5s against 0.27s for a million entries). The payload now bounds the field to the number of ids the normalizer can store, and the dedupe uses a set. A settings target opened from the Chat model picker carried no apiLoadable, so the isGguf fallback mirrored an Ollama GGUF to the server. Ollama's blobs reach that picker as custom-folder GGUFs under a .studio_links / ollama_links dir, which local_model_resolver refuses to index, so the mirror advertised a load the API can never make. The picker, the sidebar editor and the backfill now all use the same classification. The Hub settings page compared the loaded model to settingsTarget.id, but a GGUF loaded from an inactive HF cache or straight off disk loads by path while /status reports the clean public id, so the page ignored the live launch config and showed saved or default values. It now also matches the settings identity and the public id the backend would report. A standalone .gguf gets a filename-derived format_variant from the inventory, so the Hub row menu stored its settings under <path>:Q4_K_M while the Chat picker, the detail card and the backfill all used the bare path. The row menu now uses the bare path too. Tests: publicModelId / residentModelIdMatches / isOllamaLinkPath / settingsGgufVariantForRow in studio/frontend/tests, the create-only write and the gpu_ids bound in studio/backend/tests, and the wiring in tests/studio/test_model_picker_contracts.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Read the concrete model key first, and fill settings in field by field Three follow-ups on the last round. The auto-switch load tried the advertised id before the concrete load path, so an entry under the alias shadowed the settings the user had just saved and kept shadowing them. The settings page keys every local row by the path being loaded, while the alias is derived: the /v1/models name a hand-written overrides PUT is written against, and for a loose .gguf only its filename stem. Each pair now reads the path first, and the alias, the bare ids and the older filename-label key are all still read after it, so a cached repo (keyed by its repo id, which is the alias) resolves exactly as before. publicModelId collapses two paths that share a filename or a directory basename onto one id, so the resident check added last round could mark the wrong catalog row as loaded and seed its editor with another model's live launch config, then save that under this model's key. The Hub page now records the loadable identifier /status reports, as every other status reader already does, so the literal comparison names one row; the public-id pass only accepts a namespaced repo id, the one collapse that cannot name two models. The override map shipped before this browser mirror did, holding only llama_extra_args and max_seq_length, so an upgraded install can have a server entry for a model whose context, KV cache, speculative and GPU settings live only in localStorage. The backfill read key presence as done, skipped exactly those models and then marked itself complete, so their API loads lost the settings for good. Filling them in from the browser would reopen the race the conditional write just closed, so the merge is the server's: the PUT flag is now fill_absent_fields, and studio_db merges field by field under the write's own transaction, where a stored value always wins. A fill with an entry already there also stops replaying that entry's stored flags through validation, so one denylisted since it was saved cannot 400 the one-time migration. Tests: the key order and the fill in studio/backend/tests, the collapse in studio/frontend/tests, and the wiring in tests/studio/test_model_picker_contracts.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Attribute API-key traffic on the lifecycle rows it creates The overlay opens on API-key traffic only, and record_lifecycle never set that flag. Auto-switch and auto-download run before the endpoint opens its request row, so a switch or a download that is refused never reaches api_monitor.start and its lifecycle row is the whole trace of the request. The monitor therefore stayed shut on exactly the failures automatic observability is for. The row now carries the attribution: a load takes it from the request that drove it, and auto-download passes it directly, since only an API request reaches that path at all. A manual unload and an idle unload are not API traffic and stay unattributed, so neither pops the overlay. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Treat a loaded standalone GGUF as resident despite its derived quant A loose .gguf keys its settings by the bare path with no variant, since the path already names the one file. The loader still derives a label from the filename and /status reports it, so the settings page compared a derived quant against a deliberate null, never matched, and withheld the live launch config from the very file that was loaded. Applying from that page could then write the saved values over what the resident model is running with. The variant equality is skipped for that case only. Every other target, a repo row or a directory, still has to agree on the quant, because there the variant is what tells two loaded copies apart. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Show the Hub the settings the model is really running with Two ways the Hub's per-model settings page could offer a resident model's saved or default values as if they were live, and then write them back over the running config on Apply. ModelConfigPage seeds its editable state from loadedConfig in a useState initializer, so it reads that prop once per mounted instance. The sidebar entry keys its instance on a signature of the live config; the Hub's keyed on the model and quant only. Open the page before /api/inference/status has hydrated, or while that same target is still loading, and loadedConfig flips from null to the live config after mount with nothing to remount on: the editor keeps the values it seeded from and Apply reloads the model with them. Both hosts now mount under one shared key that includes the live config, so the arrival of that config re-seeds the editor and a repeated poll of the same values does not. The live config itself comes out of the chat runtime store, and landing straight on /hub is the one entry point where nothing has applied the status yet: useChatModelRuntime has no mount sync and the chat page is a different route. The Hub's own status effect pinned the checkpoint and stopped there, so the resident check passed while kv cache, speculative decoding, tensor parallel and every GPU placement field still held their defaults. It now applies the whole status, the same call the chat runtime's refresh makes, and holds off when a load owns the store or an external provider is selected so it cannot fight either. * Pop the monitor open only for the traffic a caller made Lifecycle rows are shared so a load or a download shows up in everyone's monitor list, which is deliberate. Since they started carrying via_api_key they also carry the flag the floating panel auto-opens on, and that reached every authenticated subject: another logged-in browser sprang open for API traffic it had nothing to do with. The row now records the caller that drove it and reports the attribution only to them. Visibility is untouched, so the row still appears for everybody, and a subject-scoped Clear hides a shared row it owns rather than deleting it out of everyone else's history. Auto-download had the flag hardcoded on, reasoning that only an API request gets that far. Only a /v1 request does, which is not the same thing: Studio's own chat calls those same endpoints with a session JWT, so a chat that named a model this server does not have popped the panel open mid-chat, which is exactly what via_api_key exists to prevent. The attribution now comes from the request that asked for the download. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Require the scanner's own label before folding a .gguf colon suffix The frontend splitter accepted any suffix after a .gguf head, while the backend requires that suffix to be the label the scanner derives from the filename. A colon is legal in a POSIX filename and POSIX is case sensitive, so "/models/llama.gguf:Bar.gguf" and "/models/llama.gguf:bar.gguf" are two real, distinct files. The one-time backfill folded both onto one override key, since the variant half is stored lowercased, and then re-read the local configs by that key, so the first entry was sent twice, the second file's context and KV cache settings never left the browser, and the done flag was set anyway. splitQuantSuffix now ports extract_quant_label for a bare filename, shard suffix and float-precision fallback included, and takes the suffix only when it equals that label. Checked against the backend over twenty-nine keys with identical answers on both sides, up from twelve, and the backfill now migrates both files with their own settings. The identity helpers come straight from features/hub/lib/model-identity rather than the hub barrel, which also re-exports the download manager and its React components. Same bindings, and it puts the module within reach of a test. The 27 new frontend assertions cover the split case by case against the backend's answers, and pin the storage rule the backfill's re-read depends on: two spellings of one repo id or one Windows path keep a single record, a POSIX path keeps two, and importing the legacy load settings never adds a duplicate. * Point the parallel-slots contract at the shared config signature main added nParallel to sidebar-model-config.tsx's own configSignature while this branch moved that helper into config-signature.ts so the hub, the sidebar and the model config page key one editor instance the same way. The behaviour is unchanged, so the assertion follows the expression to where it now lives and pins the sidebar to the shared key. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refresh Hub inference status after API-driven model switches The Hub read /api/inference/status once, on mount. An OpenAI-compatible request can auto-switch the resident model at any time, and nothing else on /hub reads status, so every 'loaded' marker and, more importantly, the per-model settings page kept describing the model that was resident when the Hub opened. The cost is concrete: with a stale checkpoint the newly loaded model fails settingsTargetIsResident, its settings page is handed loadedConfig=null, ModelConfigPage seeds the editor from saved or default values, and Apply reloads the model with them over the launch settings the API selected. Re-read on the moments this tab could have missed a switch -- regaining focus or visibility, and opening a model's settings page -- rather than polling on a timer. adoptResidentModelStatus already stands down for an external selection and for a load this tab started, so re-running it never fights the model the user is switching to, and applying an unchanged status leaves the live config identical, so the settings editor is not remounted under a draft. * Mirror per-model parallel slots to API loads and keep launch flags on eviction Parallel decode slots are a per-model setting the picker sends on every GGUF load, but the server-side override the OpenAI-compatible auto-switch path reads never carried them, so an API load of the same model fell back to the server-wide --parallel default. llama_extra_args could not stand in either, since --parallel is on the managed denylist. A config whose only change was the slot count also serialized to an empty payload, so the one-time backfill sent nothing and still marked itself done. Carry n_parallel through the serializer, the override schema, the normalizer and the load kwargs, and list it on the monitor so such an entry no longer reads as app defaults. Evicting a model to stay inside the browser's storage budget also sent the same full remove an explicit Forget does, which wiped llama_extra_args set through the settings API that no UI can show or restore, for a model the user never touched in that save. Eviction now clears only the mirrored fields; the route already carries stored launch flags over and drops the row once nothing is left. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep native-leased GGUFs off the API mirror and date the monitor's first snapshot Two identities the OpenAI-compatible auto-switch can never reach were being treated as if it could. A dropped or file-picked GGUF loads through a signed native-path lease, and /api/inference/status reports model_identifier as null for it, so the checkpoint the browser keys settings by is the bare file name the backend echoes back. local_model_resolver._build_index keys a standalone GGUF by its on-disk path and by its .gguf-stripped stem, so that name is never an index key: mirroring it wrote a server override no load can read, and the monitor's "Settings applied on API load" list then advertised it as live. The save gates on the lease token rather than the name, since the label falls back to a plain string with no suffix, and the one-time backfill, which has no token to read, goes by the identity shape. The floating monitor also wrote off every finished row of its first snapshot as history. That snapshot is not taken at mount: a hidden tab issues no fetch at all and an unreachable backend fails one, so the first API call of the session can start and finish before it lands and never open the panel. Date the backlog from when the poll stood up instead, on the server's own clock minus a browser duration, so a browser that disagrees with the server cancels out rather than replaying the whole ring buffer. A backend with no clock field keeps the old behaviour. The decision moves into its own module so it can be driven without a browser, which the overlay's .tsx dependency graph rules out. * Record a monitor row for a /v1 call refused by auto-download * Key a standalone GGUF's settings the same way on every surface llama_cpp falls back to _extract_quant_label(gguf_path) when a load names no variant, and /status echoes that as gguf_variant, so the sidebar wrote settings under <path>:Q4_K_M while the Hub row, the picker and the backfill used the bare path. The auto-switch lookup reads target_id before target_id:file_variant, so the bare entry always won and a sidebar edit could never reach an API load. The sidebar now nulls the variant for the settings identity, the same rule settingsGgufVariantForRow applies to a Hub row, sharing one definition. The displayed label still carries the quant. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Judge residency on settled state, not a snapshot Three places read the resident model from state that had moved on. The Hub's variant lookup closed over rowIsActive and activeGgufVariant before awaiting listGgufVariants, while the same click also starts a status refresh, so the two race and the network call usually loses. The residency test now happens after the await against the settled store, and skips an external checkpoint. adoptResidentModelStatus returned early on an empty status, before the external and modelLoading guards, so an unload from another tab or over the API left the store pinned and the settings page went on treating that row as resident. The empty branch now sits after both guards and clears only a local checkpoint. The monitor's Unload targets the resident local model from /status but cleared the store checkpoint unconditionally, and clearCheckpoint drops the persisted external selection too, so unloading wiped an external pick it never touched. It now clears only when the store holds the model that was unloaded. * Reject booleans for the numeric override fields Pydantic parses non-strictly and bool subclasses int, so a payload with max_seq_length true was stored as 1, a one-token context, and gpu_ids [true] as [1], an unintended GPU pin. _bounded_int already rejects bools for exactly that reason, but never saw one: coercion happens at the route boundary first, which left that guard unreachable through this path. A mode=before validator rejects only booleans, including inside the gpu_ids list, so every other lax conversion still runs and tensor_parallel, remove and fill_absent_fields keep working. * Match both spellings when the monitor unloads a model My earlier fix compared the store checkpoint only against the identifier /status reports, which is the concrete load path. For a GGUF loaded through auto-switch or from a non-active cache the store can hold the advertised repo id instead, so the comparison failed and the store stayed pinned to a model that had just been freed. Match the load path and the public alias, and keep the external guard that prompted the narrower comparison. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Read status before the settings target resolves a quant Two fixes to the API monitor and the Hub settings open. The overlay rearm only cleared `seeded`, so returning from the full page ran the initial-watch seed, and that seed holds running rows back on purpose: at a session's first snapshot such a row started while Studio was loading and nobody has seen it. Off the full page the opposite is true, since that page was showing the same feed with its running rows, so the overlay reopened on exactly the request the user had been reading. The seed now records whether it follows a rearm. Opening a cache row's settings resolves its quant from the store, and the comment there claimed the open kicks a status refresh that lands during the variant lookup. It does not: that refresh comes from an effect keyed on `settingsTarget`, which cannot run until the target exists. The Hub has no polling timer either, re-reading on focus and visibility only, so a window that keeps focus holds a checkpoint from before any API-driven switch for as long as the user sits there, and the editor opens on the quant of whichever model that switch displaced. The handler now reads status itself, alongside the variant lookup. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clear the derived gguf key on forget and key cached repos by repo id Three fixes. A standalone .gguf is keyed by its bare path, but a load also reads the `<path>:LABEL` entry derived from the filename, which is how the picker keyed the same file before and what the backfill carries over from an upgraded browser. `resolve_model_override_key` cannot fold a bare path onto that spelling, so forgetting cleared a key that was never there and left the real one applying to every later API load, with the settings gone from the UI and nothing left to reach them. The remove branch now clears the derived key too, only for a bare `.gguf` path and only through the resolver, so an ambiguous fold removes nothing. `modelConfigIdentity` decided from the view kind what its own comment says depends on the row. Discover resolves a repo in an inactive HF cache into a discover-kind view whose resource is still the cache row with its snapshot-path run id, so Run from Discover read a key the Downloaded row never writes and loaded with default context, GPU and template, silently. Keyed off the resource instead. The detail view's settings button took the card's quant as given. That quant is a choice only when the user picked it in the selector; otherwise it comes off a store nothing re-reads while the window keeps focus. The card now says which it is, and a derived one defers to a fresh status read. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Consolidate the tests without changing what they prove Test-only. No file under studio/frontend/src or the backend's routes, core, hub, utils or storage is touched. Backend: the override PUT was spelled out at 29 sites as a two-call expression with a local import each time, and 39 tests mocked the store by hand when a fixture does it. A `_put` helper and an `override_store` fixture take both. -186 lines, 273 tests still pass, and the assert count is unchanged at 624. Frontend: eight test files become five plus a shared kit holding the bundler-resolver registration, the localStorage fake and the chat-runtime store fakes that three files had each written out. The resident-status pair merge into one file, and the three identity/storage files into another. 62 tests, 139 assertions, both unchanged. Prose: multi-line docstrings and comment blocks in the test suites keep their opening statement, the rest being recoverable from history. That is most of the remaining reduction, because these tests are close to one line per assertion already. Two consolidations were measured and rejected rather than shipped. A table-driven form of the source-contract tests generates 930 lines to replace 773, since a row costs what an assert line costs. Parametrising the backend key-folding and carry-over families saves nothing once the helper above removes their boilerplate: what is left is the per-case reason, not repetition. Every mutation these tests were written to catch still reddens: reverting new-traffic.ts, adopt-inference-status.ts, settings.py and hub-page.tsx to their pre-fix parents each fails the expected tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Resolve a local quant folder's variants by its path A local row carries a repo id only inside the HF cache: getLocalHubId returns null unless the source is hf_cache. A plain folder of quants, under the models dir, a custom folder or LM Studio, therefore has none, while the backend still marks it as needing one, since requires_variant is scan_path.is_dir(), and leaves format_variant null because a directory is not the single-file case. settingsGgufVariantForRow then returns null, the guard is entered, the lookup is skipped for want of an id, and the toast is all that is left. The row menu offers Settings on every non-dataset row, so for these folders it could never do anything. The listing takes a path in that position and scans it, before the repo-id validation that would otherwise reject one, and that is already the request the on-device card makes: its fetch state is keyed on the model id, which for a local row without a hub id is the load path. So both surfaces now choose from the same quant set, and the settings key, the row's load id, does not move. * Stop clearing the checkpoint on an empty status, and forget every spelling Three fixes. An empty /status is not the same as the model going away. With idle unload on, the server frees the resident GGUF and keeps a stash the next request reloads, while get_status never consults that stash and InferenceStatusResponse carries no field that tells the two apart. The store is what names the model meanwhile, for the usage examples and for the reload itself, and usage-examples.tsx says as much in its own words. The chat runtime, which this adoption mirrors, resets only capability flags on an empty status and leaves the checkpoint pinned; every other clearCheckpoint caller is tied to the action that caused the unload. The clear I added two rounds ago is removed, along with the test that pinned it. Forgetting an override cleared only the key a load resolves to. An exact hit short-circuits before the fold, so with two spellings of one model in the map, from a build whose setter stored the literal id, the one named went and the survivor became the sole fold match: the next load applied what had just been forgotten. The folding rule is factored into one helper and the remove branch clears every key under the identity. POSIX paths still stand alone, so two files never clear each other. Both settings handlers now read status before building a target rather than only in the variant branch. Every path out of them seeds the editor from the store and Apply reloads with what it seeded, so a target built on a pre-switch read could reload a resident model from defaults and overwrite its saved config if applied before the read landed. A quant the user picked in the card is still preserved. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the comments across this branch Collapse multi-line comment blocks to one or two lines and drop comments the code already states. The reasons behind the non-obvious rules are kept, just shorter: the empty-status checkpoint rule, POSIX vs Windows path folding, the legacy path:LABEL override key, reading status before building a settings target, and the rearm seed. Comments only, no code changes. * Never let the monitor's api-key check fail a load _request_used_api_key decides one monitor label, and it already wraps the header read in a try because a caller may hand it anything. The unpack sat outside that guard, so a request object whose headers answer with a non-string reached `scheme, _, token = header.partition(" ")` and raised ValueError out of _load_model_impl. That is exactly how the load routes are driven in tests: a MagicMock request answers every attribute, so `or ""` never fires and the unpack blows up. Twelve tests across test_nvfp4_load_error_message.py, test_validate_gguf_runtime_message.py and test_chat_load_during_training.py failed on this branch and pass on its merge base, which is where this came from. The full backend suite now matches that baseline exactly at 17 pre-existing failures in the same six modules, down from 29. Made the helper total rather than teaching three test modules to build a better double, because a best-effort label must not take a load down for any caller. * Read an empty status against the idle setting, and keep repo quants Two fixes. An empty /status means one of two things and the payload cannot say which. Armed, the idle loop frees the model but keeps a stash the next request reloads, and the store is what names it meanwhile. Disarmed, nothing will bring it back, so the model is gone and leaving the row resident seeds the settings editor from a launch config nothing is running. Neither always-clear nor never-clear is right, and the only endpoint that knows is /openai-auto-switch, which already reports idle_unload_active for the usage examples. The Hub reads it once and the empty-status branch clears only while the loop is disarmed, which is the default. isStandaloneGgufPath tested the .gguf suffix alone. Repo ids ending in .gguf are real on the Hub, an iMat repo among them, and those hold every quant of a model, so reading one as a single file dropped its variant and saved Q4 and Q8 under the same key. The id now also has to name something on this machine: a path anchor, a drive, a second separator, or the bare filename a picked file is echoed back as. A repo id has none of those. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Merge main, and read backend sources as UTF-8 in the contract tests _read_backend arrived with the local MTP drafter work and reads without an encoding, three lines below _read, which passes one. On Windows that decodes as cp1252, and routes/inference.py holds byte 0x81 at offset 103058, so the Windows job died with UnicodeDecodeError before any assertion ran rather than reporting a contract. Same failure on main today; it shows up here because this branch touches the file the helper reads. * Fix six issues the review found, one of them in my own last fix chat_template_override: I gated the re-seed on seedLoadParams and said it implied hydratingExistingModel. It does not, it is !modelLoading, so the re-seed also fired on an ordinary poll and overwrote an unsaved edit on every refresh. It needs both, which is what the neighbouring fields do. The idle-unload setting was read once and cached for the life of the page, so a timeout changed from Settings while the Hub stayed mounted was never seen, and a transient failure stuck on the default forever. It is read with every status read now, keeping the last answer on failure rather than falling back to the default: the default is disarmed, which is the side that clears the checkpoint. Forgetting repo:QUANT cleared the bare repo entry unconditionally, which strips the quants inheriting from it. It is only cleared when no other qualified key survives, so the forget has to be the last word on the model. Provenance would be the better test but nothing records it: the carry-over copies the flags without marking the copy. ApiMonitor.clear dropped an own row that was still running, losing the request outright, since active_count falls to zero and the finish that follows has no entry to land on. The shared pass in the same function already keeps a running row; both follow that rule now. The scoped-clear test used a running row only because start() is the only way to make one, so it finishes the request first and the running rule gets its own test. A cached repo is keyed by its repo id now and used to be keyed by the snapshot path, and resolveInitialConfig has no fallback, so an upgrade read as never remembered. adoptLegacyConfigKey moves the record once, before either entry point reads the new key; a newer save under the new key wins. The server override mirror sent gpu_ids without their namespace. The same integers are Vulkan ordinals under Vulkan and device indices elsewhere, and the server cannot tell, so only a physical pin travels. An absent kind is a record written before the field existed, which was physical only. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix the argument order in adoptLegacyConfigKey, which broke the build savePerModelConfig takes (modelId, ggufVariant, config); the new adoptLegacyConfigKey passed (modelId, config, ggufVariant). tsc caught it, so `npm run typecheck` and `npm run build` both fail at head, and since `npm run build` is the first thing studio setup does, the failure cascades: 30 of 51 checks are red on eb4c3eac8, including startup on all three platforms, the wheel and Tauri builds, Chat UI Tests and every connection job. The runtime effect is worse than the type error suggests. normalize() receives a string, hits its `typeof raw !== "object"` guard and returns an all-defaults config, isDefaultConfig() then reads that as default and takes the delete branch, so nothing is written at all. deletePerModelConfig on the next line still drops the real legacy record, and savePerModelConfig has already returned true. The migration that exists to carry settings across the re-key destroys them instead, and reports success. The contract test asserted the call as a literal source substring, so it passed on the broken code. Corrected alongside, but a substring assertion cannot catch an argument order, so this also adds tests/adopt-legacy-config-key.test.ts, which drives the real storage module and asserts the moved config's values rather than the presence of a key. Two of its four cases fail on the previous head and pass here; the other two cover the paths that never reach savePerModelConfig. Verified: typecheck clean, 103/103 node tests, production build clean, 122 passed across the three tests/studio contract files, and 316 passed across test_openai_auto_switch.py and test_api_monitor.py. * Take the cosmetic churn back out of the test diff The test diff carried 90 pre-existing functions that this branch only reworded or re-wrapped comments in. That is what made the three test files read as 2446 lines of change when the behavioural part is far smaller, and it is the reason the deletion count looked like coverage had been removed: 342 of the deletions were comments being reflowed to a wider budget, not assertions going away. Restored those functions to their merge-base text wherever the change was provably cosmetic. Two rules decided it: comments never reach the AST, so they drop out for free, and a docstring only counts as cosmetic when its word sequence is unchanged, so the 16 docstrings this branch genuinely rewrote are left alone. The executable AST of all three files is byte-identical to the previous head. Also pointed the 19 backend source reads the new tests had inlined at the _read_backend helper the file already owns, which is what the pre-existing tests in it use. Seven of them spanned three physical lines to repeat a path and an encoding argument. The two inlined reads inside pre-existing tests are left as they are, since rewriting those would add diff rather than remove it. No test and no assertion was removed: 278/633, 107/250 and 102/547 before and after, per file. The three files go from 2446 to 2094 lines of diff, and the PR from 9854 to 9502. Verified: 2141 passed and 3 skipped across tests/studio, 486 passed across test_openai_auto_switch.py, test_api_monitor.py, test_openai_auto_download.py and test_parallel_slots_per_load.py. * Fix three ways a saved per-model config is lost or ignored Three review findings, each a case where the settings a user saved stop being the settings a load applies. routes/settings.py, utils/openai_auto_switch_settings.py. A repo cached outside the active HF cache has two spellings: the snapshot path it loads from, and its repo id. The one-time backfill mirrors whichever one localStorage held, a later Settings save writes the other, and nothing retired the first, so the override map ended up holding both. The load ladder reads the path before the advertised id, so every API auto-load kept applying the pre-migration config and the save looked like it had done nothing. Reordering the ladder would only move the bug: an inactive-cache repo is sometimes legitimately keyed by its path, when inventory-dedupe keeps no cached row for it. So a save or a forget now retires the other spelling of the same (repo, quant), which is the one-entry-per-model invariant this route already enforces for casing folds, the <path>:LABEL legacy key and the bare repo id. The retired entry hands over its llama_extra_args first, since the page can neither show nor restore them. The one-time fill retires nothing, or the migration would delete what it is meant to carry. per-model-config.ts. adoptLegacyConfigKey saved under the new id before deleting the old one, so at the entry or byte budget the map briefly held both copies and the save evicted the oldest unrelated model to fit: silently, still reporting success, and with no eviction list to hand back, so that model's server override kept applying with nothing in the UI able to forget it. The key is renamed in one write now, which cannot grow the entry count. Reachable well before the entry cap via the byte cap: 17 models each carrying a 60 KB chat template sit under 1 MiB. apply-inference-status-to-store.ts. An API auto-switch hands the loader the concrete snapshot path, so /status reports that path while the model's settings are keyed by its repo id. Reading remembered slots by the raw identifier missed the record, cleared nParallel on the model change, and the next save wrote the blank back over both localStorage and the server entry. Resolved through the cached-repo alias now, and only for namespaced ids: a file stem two models can share must never be read, or one model's settings apply to another. Two further findings are not changed here, with the reasoning in the PR thread: the duplicate-key collapse cannot affect a load, because the override keys come from the resolver and never from the request's spelling; and preserving a bare repo entry for a quant that inherits it is unsatisfiable alongside test_forget_clears_the_bare_repo_entry_the_quant_inherited_from, since neither case leaves anything in storage to tell them apart. Verified: 492 passed across the four backend suites this touches, 2142 passed and 3 skipped across tests/studio, 118 node tests, typecheck, build and ruff clean. Each fix was confirmed against its own tests on the unfixed source first: 4 fail for the alias retirement, 2 for the eviction, 5 for the slot lookup, and the two over-deletion guards pass either way. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the comments this branch added Same reasons, fewer lines: collapse the multi-line explanations added by this branch and drop three field docs that only restated the field name. No code, docstring or test changes. * Stop a lower-priority override key holding newer data than the one a 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. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Stop the monitor acting on a snapshot the API has already moved past 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. * Release a settings open on the refresh that superseded it, and report 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. * Serialize override writes so two spellings cannot retire each other 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. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
215 lines
10 KiB
Python
215 lines
10 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Static contract for which model the API usage examples name, and for the
|
|
model-auto-switch control living in exactly one place on the API keys tab."""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
SETTINGS = REPO / "studio/frontend/src/features/settings"
|
|
USAGE_EXAMPLES_TSX = SETTINGS / "components/usage-examples.tsx"
|
|
OPENAI_MODELS_TS = SETTINGS / "api/openai-models.ts"
|
|
API_KEYS_TAB_TSX = SETTINGS / "tabs/api-keys-tab.tsx"
|
|
|
|
|
|
def test_examples_name_a_model_the_server_can_serve():
|
|
# A hardcoded repo id made copied curls 404; read the servable ids from /v1/models.
|
|
src = USAGE_EXAMPLES_TSX.read_text(encoding = "utf-8")
|
|
assert 'from "../api/openai-models"' in src
|
|
assert "function useExampleModelName(): string" in src
|
|
hook = src[src.find("function useExampleModelName") : src.find("// Backend PATH detection")]
|
|
assert "listOpenAIModels()" in hook
|
|
# Precedence: live checkpoint, then a loaded entry, then any entry if switching is on.
|
|
assert "catalog?.find((m) => m.loaded) ?? (autoSwitch ? catalog?.[0] : undefined)" in hook
|
|
# The snippet pins the quant so the request names the file on disk.
|
|
assert "`${pick.id}:${pick.quant}`" in hook
|
|
|
|
api = OPENAI_MODELS_TS.read_text(encoding = "utf-8")
|
|
assert 'authFetch("/v1/models")' in api
|
|
|
|
|
|
def test_examples_never_print_a_hardcoded_model_id():
|
|
# The bug this exists for: a `[]` catalog printed a snippet before /v1/models answered.
|
|
# It is tri-state now, and the panel asks for a model instead.
|
|
src = USAGE_EXAMPLES_TSX.read_text(encoding = "utf-8")
|
|
assert "MODEL_FALLBACK" not in src
|
|
# No repo-shaped literal anywhere: a snippet may only name what /v1 returns.
|
|
assert re.search(r'"unsloth/[^"]+"', src) is None
|
|
assert "function useExampleModelName(): string | null" in src
|
|
assert "useState<OpenAIModel[] | null>(null)" in src
|
|
# Nothing servable means nothing is built, so there is nothing to copy.
|
|
assert "(model ? buildSnippets(base, key, model, os) : null)" in src
|
|
assert "if (!snippets) return;" in src
|
|
assert "{snippets ? (" in src
|
|
assert 't("settings.apiKeys.usageNoModel")' in src
|
|
|
|
en = EN_TS.read_text(encoding = "utf-8")
|
|
assert "usageNoModel:" in en
|
|
|
|
|
|
def test_catalog_refresh_follows_the_loaded_model():
|
|
# A dep list missing these never re-ran, so a finished load left the first fetch's
|
|
# name. Nor may it be gated on having no checkpoint: the store keeps one across an
|
|
# idle unload, which changes nothing React can see.
|
|
src = USAGE_EXAMPLES_TSX.read_text(encoding = "utf-8")
|
|
hook = src[src.find("function useExampleModelName") : src.find("// Backend PATH detection")]
|
|
assert "}, [checkpoint, ggufVariant]);" in hook
|
|
assert "needsCatalog" not in hook
|
|
# A finishing download moves no store state, so the fetch retries on a timer too,
|
|
# and residency only slows that timer rather than stopping it.
|
|
assert "CATALOG_RETRY_MS" in hook and "CATALOG_IDLE_MS" in hook
|
|
assert "window.clearTimeout(timeoutId)" in hook
|
|
assert "const CATALOG_RETRY_MS = 15000;" in src
|
|
assert "const CATALOG_IDLE_MS = 60000;" in src
|
|
|
|
|
|
def test_a_stored_checkpoint_needs_catalog_evidence():
|
|
# The store keeps a checkpoint across an idle unload and across a deletion, so
|
|
# preferring it on the switch setting alone named a model /v1/models had proved
|
|
# absent, and the snippets 404d instead of falling back.
|
|
src = USAGE_EXAMPLES_TSX.read_text(encoding = "utf-8")
|
|
hook = src[src.find("function useExampleModelName") : src.find("// Backend PATH detection")]
|
|
assert 'const entry = catalog?.find((m) => sameBaseModelId(m.id, checkpoint ?? ""));' in hook
|
|
# Resident, or downloaded with something able to reload it. Never the setting alone.
|
|
assert "(!!entry && (entry.loaded || autoSwitch || idleReload))" in hook
|
|
assert "autoSwitch ||\n" not in hook
|
|
|
|
|
|
def test_standalone_idle_unload_still_names_the_stored_checkpoint():
|
|
# UNSLOTH_MODEL_IDLE_TTL without auto-switch reloads exactly what it freed, so the
|
|
# stored checkpoint stays runnable and the panel must keep showing it. The stash
|
|
# restores only that model, so it can never pick catalog[0].
|
|
src = USAGE_EXAMPLES_TSX.read_text(encoding = "utf-8")
|
|
hook = src[src.find("function useExampleModelName") : src.find("// Backend PATH detection")]
|
|
assert "const [idleReload, setIdleReload] = useState(false);" in hook
|
|
assert "setIdleReload(settings[1])" in hook
|
|
assert "s.idleUnloadActive" in hook
|
|
# fromCatalog stays gated on auto-switch alone.
|
|
assert "?? (autoSwitch ? catalog?.[0] : undefined)" in hook
|
|
assert "idleReload ? catalog" not in hook
|
|
|
|
|
|
def test_a_failed_refresh_does_not_erase_what_the_server_holds():
|
|
# Catching into [] and false made a transient error authoritative: the panel dropped
|
|
# a still-servable model and printed "No model". The catalog is deliberately
|
|
# tri-state, and a failure must stay the unknown state.
|
|
src = USAGE_EXAMPLES_TSX.read_text(encoding = "utf-8")
|
|
hook = src[src.find("function useExampleModelName") : src.find("// Backend PATH detection")]
|
|
assert "listOpenAIModels().catch(() => null)" in hook
|
|
assert ".catch(() => null)," in hook
|
|
assert "if (models !== null) setCatalog(models);" in hook
|
|
assert "if (settings !== null) {" in hook
|
|
# The old negatives must be gone entirely.
|
|
assert "catch(() => [] as OpenAIModel[])" not in hook
|
|
assert "catch(() => [false, false] as const)" not in hook
|
|
assert "catch(() => false)" not in hook
|
|
|
|
|
|
def test_the_pinned_quant_comes_from_the_catalog():
|
|
# Catalog membership proves the repo, not the saved quant: the stored one can name
|
|
# a file deleted while another quant remains, so pinning it 404d on a missing quant
|
|
# with a runnable one listed.
|
|
src = USAGE_EXAMPLES_TSX.read_text(encoding = "utf-8")
|
|
hook = src[src.find("function useExampleModelName") : src.find("// Backend PATH detection")]
|
|
assert "const quant = catalog === null ? ggufVariant : entry?.quant;" in hook
|
|
assert "`${checkpoint}:${ggufVariant}`" not in hook
|
|
|
|
|
|
def test_usage_examples_has_no_duplicate_auto_switch_control():
|
|
# ModelAutoSwitchSection renders this setting just below and shares no state with it.
|
|
src = USAGE_EXAMPLES_TSX.read_text(encoding = "utf-8")
|
|
# Reading the setting is fine; writing it here is what would be a second control.
|
|
assert "updateOpenAIAutoSwitchSettings" not in src
|
|
assert "SWITCH_NOTE" not in src
|
|
assert "Switch model by request" not in src
|
|
assert "pythonSwitchDemo" not in src
|
|
assert "javascriptSwitchDemo" not in src
|
|
assert "modelAutoSwitch" not in src
|
|
|
|
tab = API_KEYS_TAB_TSX.read_text(encoding = "utf-8")
|
|
assert "<ModelAutoSwitchSection />" in tab
|
|
|
|
|
|
# The monitor moved onto its own page; Settings keeps configuration and links across.
|
|
API_MONITOR_TSX = REPO / "studio/frontend/src/features/api-monitor/api-monitor-page.tsx"
|
|
# Their own module: the overlay mounts from __root.tsx, so importing from the page
|
|
# pulled it into the eager bundle.
|
|
API_MONITOR_LIFECYCLE_TS = REPO / "studio/frontend/src/features/api-monitor/lifecycle.ts"
|
|
MONITOR_LINK_TSX = SETTINGS / "components/monitor-link.tsx"
|
|
|
|
|
|
def test_api_monitor_history_does_not_reorder_under_the_reader():
|
|
# The backend moves an entry to the front as it finishes, so the page pauses the poll
|
|
# to hold the whole list still while a payload is read.
|
|
src = API_MONITOR_TSX.read_text(encoding = "utf-8")
|
|
assert "paused" in src
|
|
assert "setPaused" in src
|
|
# Filters and search are what keep 50 rows usable without paging.
|
|
assert "filterEntries(" in src
|
|
assert "STATUS_FILTERS" in src
|
|
|
|
|
|
def test_api_monitor_renders_lifecycle_rows():
|
|
src = API_MONITOR_TSX.read_text(encoding = "utf-8")
|
|
labels = API_MONITOR_LIFECYCLE_TS.read_text(encoding = "utf-8")
|
|
assert "export function isLifecycleEntry(" in labels
|
|
assert 'entry.kind === "lifecycle"' in labels
|
|
for label in ("Loading model", "Model loaded", "Model unloaded"):
|
|
assert label in labels
|
|
# A lifecycle row has no prompt or reply, so it is not selectable for detail.
|
|
assert "if (isLifecycleEntry(entry)) {" in src
|
|
assert 'from "./lifecycle"' in src
|
|
|
|
|
|
def test_auto_switch_section_sits_above_the_usage_examples():
|
|
tab = API_KEYS_TAB_TSX.read_text(encoding = "utf-8")
|
|
# Configuration still comes ahead of the examples that depend on it.
|
|
assert tab.index("<MonitorLink />") < tab.index("<ModelAutoSwitchSection />")
|
|
assert tab.index("<ModelAutoSwitchSection />") < tab.index("<UsageExamples")
|
|
|
|
|
|
AUTO_SWITCH_TSX = SETTINGS / "components/model-auto-switch-section.tsx"
|
|
EN_TS = REPO / "studio/frontend/src/i18n/locales/en.ts"
|
|
|
|
|
|
def test_api_monitor_renders_download_rows():
|
|
src = API_MONITOR_LIFECYCLE_TS.read_text(encoding = "utf-8")
|
|
assert 'entry.event === "download"' in src
|
|
for label in ("Downloading model", "Model downloaded", "Model download failed"):
|
|
assert label in src
|
|
|
|
|
|
def test_monitor_can_unload_the_loaded_model():
|
|
src = API_MONITOR_TSX.read_text(encoding = "utf-8")
|
|
assert "unloadActiveModel" in src
|
|
# Always rendered so the manual release stays discoverable; disabled, not hidden.
|
|
assert "disabled={unloading || !data?.active_model}" in src
|
|
assert "{data?.active_model ? (" not in src
|
|
# /unload matches on the internal id, omitted here (a host path), so read it from status.
|
|
assert "resolveInferenceCheckpointId(status)" in src
|
|
assert "unloadModel({ model_path: checkpoint })" in src
|
|
|
|
|
|
def test_settings_still_reaches_the_monitor():
|
|
# The console is gone, so Settings must still have a way through to it.
|
|
link = MONITOR_LINK_TSX.read_text(encoding = "utf-8")
|
|
assert 'to: "/api-monitor"' in link
|
|
|
|
|
|
def test_auto_download_toggle_is_gated_on_auto_switch():
|
|
# Downloading what auto-switch cannot load fetches gigabytes nothing can serve.
|
|
src = AUTO_SWITCH_TSX.read_text(encoding = "utf-8")
|
|
assert "modelAutoSwitch.autoDownload" in src
|
|
assert "settings?.autoDownloadModel ?? false" in src
|
|
row = src[src.find("modelAutoSwitch.autoDownload") :]
|
|
assert "disabled={!settings?.enabled || isSaving}" in row[: row.find("</SettingsRow>")]
|
|
|
|
|
|
def test_auto_download_copy_warns_about_api_key_holders():
|
|
en = EN_TS.read_text(encoding = "utf-8")
|
|
start = en.find("autoDownloadDescription:")
|
|
assert start != -1
|
|
description = en[start : en.find("\n", en.find('",', start))]
|
|
assert "API key" in description
|