diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts
index 884502a67e..036736930d 100644
--- a/studio/frontend/src/features/chat/index.ts
+++ b/studio/frontend/src/features/chat/index.ts
@@ -35,7 +35,10 @@ export type {
GgufVariantDetail,
InferenceStatusResponse,
} from "./types/api";
-export { resolveInferenceCheckpointId } from "./lib/apply-inference-status-to-store";
+export {
+ applyActiveModelStatusToStore,
+ resolveInferenceCheckpointId,
+} from "./lib/apply-inference-status-to-store";
export {
ChatSettingsPanel,
ParamSlider,
diff --git a/studio/frontend/src/features/hub/catalog/hub-model-settings-view.tsx b/studio/frontend/src/features/hub/catalog/hub-model-settings-view.tsx
index 3ae775d1fa..31cf3b31c8 100644
--- a/studio/frontend/src/features/hub/catalog/hub-model-settings-view.tsx
+++ b/studio/frontend/src/features/hub/catalog/hub-model-settings-view.tsx
@@ -7,7 +7,11 @@
// place to work through every knob. This gives them a page and says plainly that
// what is saved here is what an API load uses, mirrored by ModelConfigPage.
-import { ModelConfigPage, type ModelPickTarget } from "@/features/model-picker";
+import {
+ ModelConfigPage,
+ type ModelPickTarget,
+ modelConfigInstanceKey,
+} from "@/features/model-picker";
import type { PerModelConfig } from "@/features/model-picker";
import { cn } from "@/lib/utils";
import { ArrowLeft01Icon, Globe02Icon } from "@hugeicons/core-free-icons";
@@ -122,7 +126,18 @@ export function HubModelSettingsView({
{
- if (cancelled || !status.active_model) return;
- // The loadable identifier, as every other status reader records it: a GGUF
- // from a non-active HF cache or straight off disk loads by path, while
- // active_model is the clean public id (an HF snapshot's repo id, any other
- // file's filename stem). Two files that share a stem collapse onto one id,
- // so storing that would make the catalog row for one of them look loaded.
- const checkpointId = resolveInferenceCheckpointId(status);
- if (!checkpointId) return;
+ if (cancelled) return;
const store = useChatRuntimeStore.getState();
- if (
- !isExternalModelId(store.params.checkpoint) &&
- (!modelIdsMatch(store.params.checkpoint, checkpointId) ||
- !ggufVariantsMatch(
- store.activeGgufVariant,
- status.gguf_variant ?? null,
- ))
- ) {
- store.setCheckpoint(checkpointId, status.gguf_variant ?? null);
- }
+ adoptResidentModelStatus(
+ {
+ // The loadable identifier, as every other status reader records it: a
+ // GGUF from a non-active HF cache or straight off disk loads by path,
+ // while active_model is the clean public id (an HF snapshot's repo id,
+ // any other file's filename stem). Two files that share a stem collapse
+ // onto one id, so storing that would make the catalog row for one of
+ // them look loaded.
+ checkpointId: resolveInferenceCheckpointId(status),
+ ggufVariant: status.gguf_variant ?? null,
+ },
+ {
+ checkpoint: store.params.checkpoint,
+ checkpointIsExternal: isExternalModelId(store.params.checkpoint),
+ activeGgufVariant: store.activeGgufVariant,
+ modelLoading: store.modelLoading,
+ },
+ {
+ setCheckpoint: (checkpointId, ggufVariant) => {
+ store.setCheckpoint(checkpointId, ggufVariant);
+ },
+ // Landing here is the one entry point that has applied no status yet,
+ // so the settings page would read this model's live config off a store
+ // still holding defaults. Same call the chat runtime's refresh makes.
+ applyStatus: (previous) => {
+ applyActiveModelStatusToStore(status, {
+ previousCheckpoint: previous.checkpoint ?? undefined,
+ previousGgufVariant: previous.ggufVariant,
+ });
+ },
+ },
+ );
})
.catch(() => undefined);
return () => {
diff --git a/studio/frontend/src/features/hub/lib/adopt-inference-status.ts b/studio/frontend/src/features/hub/lib/adopt-inference-status.ts
new file mode 100644
index 0000000000..881a2e9d20
--- /dev/null
+++ b/studio/frontend/src/features/hub/lib/adopt-inference-status.ts
@@ -0,0 +1,92 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+// Adopting the resident model into the chat runtime store from the Hub.
+//
+// Landing straight on /hub (a reload, or a deep link after an OpenAI-compatible
+// auto-switch loaded something else) is the one entry point where nothing has
+// applied /api/inference/status yet: useChatModelRuntime has no mount sync and
+// the chat page is a different route. Pinning only the checkpoint leaves every
+// other field useActiveModelConfig reads at its default, so the Hub's settings
+// page passes those defaults on as the resident model's live config and Apply
+// reloads the model with them. Adoption therefore has to apply the whole status,
+// exactly as the chat runtime's refresh does.
+
+import { ggufVariantsMatch, modelIdsMatch } from "./model-identity.ts";
+
+/** The parts of the chat runtime store adoption has to look at. */
+export interface ResidentAdoptionState {
+ /** ``params.checkpoint``. */
+ checkpoint: string | null;
+ /** Whether that checkpoint names an external provider's model. */
+ checkpointIsExternal: boolean;
+ /** ``activeGgufVariant``. */
+ activeGgufVariant: string | null;
+ /** ``modelLoading``: a load this tab started still owns the store. */
+ modelLoading: boolean;
+}
+
+/** What ``/api/inference/status`` says is resident, already resolved. */
+export interface ResidentStatusFacts {
+ /** ``resolveInferenceCheckpointId(status)``; null when nothing is loaded. */
+ checkpointId: string | null;
+ /** ``status.gguf_variant``. */
+ ggufVariant: string | null;
+}
+
+export interface ResidentAdoptionActions {
+ /** Re-pin ``params.checkpoint`` onto the resident model. */
+ setCheckpoint: (checkpointId: string, ggufVariant: string | null) => void;
+ /**
+ * Apply the rest of the status. Receives the store values from BEFORE
+ * ``setCheckpoint`` ran, which is what applyActiveModelStatusToStore needs to
+ * tell a hydration from steady state.
+ */
+ applyStatus: (previous: {
+ checkpoint: string | null;
+ ggufVariant: string | null;
+ }) => void;
+}
+
+/**
+ * Adopt the resident model reported by ``/api/inference/status``.
+ *
+ * Returns whether anything was adopted. Never loads or unloads a model: it only
+ * mirrors what the server already has.
+ */
+export function adoptResidentModelStatus(
+ status: ResidentStatusFacts,
+ state: ResidentAdoptionState,
+ actions: ResidentAdoptionActions,
+): boolean {
+ const { checkpointId } = status;
+ if (!checkpointId) {
+ return false;
+ }
+ // An external-provider selection has no local mirror, so stamping the resident
+ // GGUF's capabilities and launch settings onto it would describe a model the
+ // user is not talking to.
+ if (state.checkpointIsExternal) {
+ return false;
+ }
+ // A load this tab started applies its own status when it settles, and the load
+ // dialog owns the params meanwhile. Adopting underneath it would fight both.
+ if (state.modelLoading) {
+ return false;
+ }
+ const previous = {
+ checkpoint: state.checkpoint,
+ ggufVariant: state.activeGgufVariant,
+ };
+ const alreadyPinned =
+ modelIdsMatch(previous.checkpoint, checkpointId) &&
+ ggufVariantsMatch(previous.ggufVariant, status.ggufVariant);
+ if (!alreadyPinned) {
+ actions.setCheckpoint(checkpointId, status.ggufVariant);
+ }
+ // Unconditional, even when the checkpoint already matched: a persisted
+ // checkpoint rehydrates from localStorage on its own, with none of the fields
+ // that say how the model was actually launched.
+ actions.applyStatus(previous);
+ return true;
+}
diff --git a/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx b/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx
index fe04363982..e80a4e4fbb 100644
--- a/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx
+++ b/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx
@@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useMemo } from "react";
-import { gpuFieldsSignature } from "../model-config/apply-per-model-config";
+import { modelConfigInstanceKey } from "../model-config/config-signature";
import { isOllamaLinkPath } from "../model-config/model-identity";
import type { PerModelConfig } from "../model-config/per-model-config";
import { ModelConfigPage } from "./model-config-page";
@@ -29,29 +29,6 @@ function leafName(id: string): string {
return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
}
-function hashString(value: string): number {
- let hash = 5381;
- for (let i = 0; i < value.length; i += 1) {
- hash = (Math.imul(hash, 33) ^ value.charCodeAt(i)) >>> 0;
- }
- return hash;
-}
-
-function configSignature(config: PerModelConfig): string {
- return [
- config.customContextLength ?? "",
- config.maxSeqLength ?? "",
- config.kvCacheDtype ?? "",
- config.speculativeType ?? "",
- config.specDraftNMax ?? "",
- config.tensorParallel ? "1" : "0",
- config.chatTemplateOverride == null
- ? ""
- : `${config.chatTemplateOverride.length}:${hashString(config.chatTemplateOverride)}`,
- gpuFieldsSignature(config),
- ].join("|");
-}
-
export function SidebarModelConfig({
modelId,
ggufVariant,
@@ -84,7 +61,7 @@ export function SidebarModelConfig({
return (
a - b).join(","),
- ].join("|");
-}
-
function gpuFieldsEqual(a: PerModelConfig, b: PerModelConfig): boolean {
return gpuFieldsSignature(a) === gpuFieldsSignature(b);
}
diff --git a/studio/frontend/src/features/model-picker/model-config/config-signature.ts b/studio/frontend/src/features/model-picker/model-config/config-signature.ts
new file mode 100644
index 0000000000..de4d99d6fe
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/model-config/config-signature.ts
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+// Identity of one ModelConfigPage editor instance.
+//
+// ModelConfigPage seeds its editable state from `loadedConfig` in a useState
+// initializer, so it reads that prop exactly once per mounted instance. A host
+// that opens the page before /api/inference/status has hydrated, or while the
+// target is still loading, gets `loadedConfig` null first and the live config a
+// moment later; without the config in the React key the same instance survives
+// that flip and keeps showing the saved/default values for a model that is
+// running with something else, which Apply then writes back over it.
+
+import type { PerModelConfig } from "./per-model-config";
+
+// Serialize the per-model GPU knobs with the same "absent == default"
+// coalescing the store applies: mode auto/absent, gpuLayers Auto (< 0) /
+// absent, nCpuMoe 0 / absent, and the GPU pick (null / absent = all GPUs).
+export function gpuFieldsSignature(config: PerModelConfig): string {
+ return [
+ config.gpuMemoryMode ?? "auto",
+ config.gpuLayers == null || config.gpuLayers < 0 ? -1 : config.gpuLayers,
+ config.nCpuMoe ?? 0,
+ config.selectedGpuIds == null
+ ? "all"
+ : [...config.selectedGpuIds].sort((a, b) => a - b).join(","),
+ ].join("|");
+}
+
+function hashString(value: string): number {
+ let hash = 5381;
+ for (let i = 0; i < value.length; i += 1) {
+ hash = (Math.imul(hash, 33) ^ value.charCodeAt(i)) >>> 0;
+ }
+ return hash;
+}
+
+/**
+ * Signature of the live config an editor was seeded from.
+ *
+ * `null` (no live config, because the model is not resident or status has not
+ * answered yet) is deliberately its own value, distinct from every real config:
+ * the arrival of the live config is exactly the transition that has to remount.
+ */
+export function loadedConfigSignature(
+ config: PerModelConfig | null | undefined,
+): string {
+ if (!config) {
+ return "none";
+ }
+ return [
+ config.customContextLength ?? "",
+ config.maxSeqLength ?? "",
+ config.kvCacheDtype ?? "",
+ config.speculativeType ?? "",
+ config.specDraftNMax ?? "",
+ config.tensorParallel ? "1" : "0",
+ config.chatTemplateOverride == null
+ ? ""
+ : `${config.chatTemplateOverride.length}:${hashString(config.chatTemplateOverride)}`,
+ gpuFieldsSignature(config),
+ ].join("|");
+}
+
+/**
+ * React key for one ModelConfigPage instance. Every host mounts it under this so
+ * they agree on when the editor is re-seeded: on a different model, a different
+ * quant, or a change in the live config it is meant to be showing.
+ */
+export function modelConfigInstanceKey(
+ modelId: string,
+ ggufVariant: string | null | undefined,
+ loadedConfig: PerModelConfig | null | undefined,
+): string {
+ return `${modelId}::${ggufVariant ?? ""}::${loadedConfigSignature(loadedConfig)}`;
+}
diff --git a/studio/frontend/tests/hub-adopt-inference-status.test.ts b/studio/frontend/tests/hub-adopt-inference-status.test.ts
new file mode 100644
index 0000000000..b21afaa349
--- /dev/null
+++ b/studio/frontend/tests/hub-adopt-inference-status.test.ts
@@ -0,0 +1,151 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ type ResidentAdoptionState,
+ adoptResidentModelStatus,
+} from "../src/features/hub/lib/adopt-inference-status.ts";
+
+const RESIDENT = {
+ checkpointId: "unsloth/Qwen3-8B-GGUF",
+ ggufVariant: "Q4_K_M",
+};
+
+function emptyStore(
+ overrides: Partial = {},
+): ResidentAdoptionState {
+ return {
+ checkpoint: null,
+ checkpointIsExternal: false,
+ activeGgufVariant: null,
+ modelLoading: false,
+ ...overrides,
+ };
+}
+
+function spies() {
+ const calls: string[] = [];
+ const previouslySeen: { checkpoint: string | null; ggufVariant: string | null }[] =
+ [];
+ return {
+ calls,
+ previouslySeen,
+ actions: {
+ setCheckpoint(checkpointId: string, ggufVariant: string | null) {
+ calls.push(`setCheckpoint:${checkpointId}:${ggufVariant ?? ""}`);
+ },
+ applyStatus(previous: {
+ checkpoint: string | null;
+ ggufVariant: string | null;
+ }) {
+ calls.push("applyStatus");
+ previouslySeen.push(previous);
+ },
+ },
+ };
+}
+
+test("landing on the Hub applies the whole status, not just the checkpoint", () => {
+ // Nothing else on /hub hydrates the runtime store: useChatModelRuntime has no
+ // mount sync and the chat page is a different route. Pinning only the
+ // checkpoint leaves every field useActiveModelConfig reads at its default, so
+ // the settings page offers those defaults as the resident model's live config.
+ const { calls, actions } = spies();
+ const adopted = adoptResidentModelStatus(RESIDENT, emptyStore(), actions);
+ assert.equal(adopted, true);
+ assert.deepEqual(calls, [
+ "setCheckpoint:unsloth/Qwen3-8B-GGUF:Q4_K_M",
+ "applyStatus",
+ ]);
+});
+
+test("a checkpoint that already matches is still hydrated", () => {
+ // A reload rehydrates params.checkpoint from localStorage on its own, with
+ // none of the fields that say how the model was actually launched.
+ const { calls, actions } = spies();
+ adoptResidentModelStatus(
+ RESIDENT,
+ emptyStore({
+ checkpoint: "unsloth/Qwen3-8B-GGUF",
+ activeGgufVariant: "Q4_K_M",
+ }),
+ actions,
+ );
+ assert.deepEqual(calls, ["applyStatus"]);
+});
+
+test("an API auto-switch under the tab re-pins the model and the quant", () => {
+ for (const stale of [
+ { checkpoint: "unsloth/Llama-3.1-8B-GGUF", activeGgufVariant: "Q4_K_M" },
+ { checkpoint: "unsloth/Qwen3-8B-GGUF", activeGgufVariant: "Q8_0" },
+ ]) {
+ const { calls, actions } = spies();
+ adoptResidentModelStatus(RESIDENT, emptyStore(stale), actions);
+ assert.deepEqual(calls, [
+ "setCheckpoint:unsloth/Qwen3-8B-GGUF:Q4_K_M",
+ "applyStatus",
+ ]);
+ }
+});
+
+test("the status applied is the one from before the checkpoint moved", () => {
+ // applyActiveModelStatusToStore tells a hydration from steady state by the
+ // previous checkpoint/quant, so it has to be read before setCheckpoint syncs
+ // them, or a variant-only switch reads as steady state and keeps the old
+ // quant's baselines.
+ const { previouslySeen, actions } = spies();
+ adoptResidentModelStatus(
+ RESIDENT,
+ emptyStore({
+ checkpoint: "unsloth/Qwen3-8B-GGUF",
+ activeGgufVariant: "Q8_0",
+ }),
+ actions,
+ );
+ assert.deepEqual(previouslySeen, [
+ { checkpoint: "unsloth/Qwen3-8B-GGUF", ggufVariant: "Q8_0" },
+ ]);
+});
+
+test("nothing is adopted when no model is loaded", () => {
+ const { calls, actions } = spies();
+ const adopted = adoptResidentModelStatus(
+ { checkpointId: null, ggufVariant: null },
+ emptyStore(),
+ actions,
+ );
+ assert.equal(adopted, false);
+ assert.deepEqual(calls, []);
+});
+
+test("an external-provider selection is left alone", () => {
+ // It has no local mirror, so stamping the resident GGUF's launch settings onto
+ // it would describe a model the user is not talking to.
+ const { calls, actions } = spies();
+ const adopted = adoptResidentModelStatus(
+ RESIDENT,
+ emptyStore({
+ checkpoint: "openai/gpt-5",
+ checkpointIsExternal: true,
+ }),
+ actions,
+ );
+ assert.equal(adopted, false);
+ assert.deepEqual(calls, []);
+});
+
+test("a load in flight is not fought", () => {
+ // The load applies its own status when it settles, and the load dialog owns
+ // the params meanwhile.
+ const { calls, actions } = spies();
+ const adopted = adoptResidentModelStatus(
+ RESIDENT,
+ emptyStore({ modelLoading: true }),
+ actions,
+ );
+ assert.equal(adopted, false);
+ assert.deepEqual(calls, []);
+});
diff --git a/studio/frontend/tests/model-config-instance-key.test.ts b/studio/frontend/tests/model-config-instance-key.test.ts
new file mode 100644
index 0000000000..02bdca38a9
--- /dev/null
+++ b/studio/frontend/tests/model-config-instance-key.test.ts
@@ -0,0 +1,129 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { modelConfigInstanceKey } from "../src/features/model-picker/model-config/config-signature.ts";
+import type { PerModelConfig } from "../src/features/model-picker/model-config/per-model-config.ts";
+
+const MODEL = "unsloth/Qwen3-8B-GGUF";
+const VARIANT = "Q4_K_M";
+
+// What the model is actually running with, as useActiveModelConfig reports it.
+const LIVE: PerModelConfig = {
+ customContextLength: 16384,
+ maxSeqLength: null,
+ kvCacheDtype: "q8_0",
+ speculativeType: "ngram",
+ specDraftNMax: 6,
+ tensorParallel: true,
+ chatTemplateOverride: null,
+ gpuMemoryMode: "manual",
+ gpuLayers: 24,
+ nCpuMoe: 3,
+ selectedGpuIds: [0, 1],
+};
+
+// What ModelConfigPage would fall back to before the live config lands.
+const SAVED: PerModelConfig = {
+ customContextLength: null,
+ maxSeqLength: null,
+ kvCacheDtype: null,
+ speculativeType: "auto",
+ specDraftNMax: null,
+ tensorParallel: false,
+ chatTemplateOverride: null,
+ gpuMemoryMode: "auto",
+ gpuLayers: -1,
+ nCpuMoe: 0,
+ selectedGpuIds: null,
+};
+
+/**
+ * ModelConfigPage reads `loadedConfig` in a useState initializer, so it seeds its
+ * editable state once per MOUNTED instance; React keeps that instance for as long
+ * as the key is unchanged. This is that rule, and nothing else.
+ */
+function renderEditor(
+ previous: { key: string; editing: PerModelConfig } | null,
+ key: string,
+ loadedConfig: PerModelConfig | null,
+): { key: string; editing: PerModelConfig } {
+ if (previous && previous.key === key) {
+ return previous;
+ }
+ return { key, editing: loadedConfig ?? SAVED };
+}
+
+test("the settings editor re-seeds when the live config arrives after mount", () => {
+ // Opened before /api/inference/status answered, or while the target was still
+ // loading: loadedConfig is null on the first render and live on the next.
+ let editor = renderEditor(
+ null,
+ modelConfigInstanceKey(MODEL, VARIANT, null),
+ null,
+ );
+ assert.deepEqual(editor.editing, SAVED);
+
+ editor = renderEditor(
+ editor,
+ modelConfigInstanceKey(MODEL, VARIANT, LIVE),
+ LIVE,
+ );
+ // Without the live config in the key the editor would still hold SAVED, and
+ // Apply would reload the model with it over what it is running with.
+ assert.deepEqual(editor.editing, LIVE);
+});
+
+test("a repeated status poll keeps the same editor instance", () => {
+ const first = renderEditor(
+ null,
+ modelConfigInstanceKey(MODEL, VARIANT, LIVE),
+ LIVE,
+ );
+ // A structurally equal config from the next poll must not remount and throw
+ // away whatever the user has typed since.
+ const again = renderEditor(
+ first,
+ modelConfigInstanceKey(MODEL, VARIANT, { ...LIVE }),
+ LIVE,
+ );
+ assert.equal(again, first);
+});
+
+test("every mirrored setting moves the instance key", () => {
+ const base = modelConfigInstanceKey(MODEL, VARIANT, LIVE);
+ const changes: PerModelConfig[] = [
+ { ...LIVE, customContextLength: 8192 },
+ { ...LIVE, maxSeqLength: 4096 },
+ { ...LIVE, kvCacheDtype: "f16" },
+ { ...LIVE, speculativeType: "off" },
+ { ...LIVE, specDraftNMax: 4 },
+ { ...LIVE, tensorParallel: false },
+ { ...LIVE, chatTemplateOverride: "{{ bos_token }}" },
+ { ...LIVE, gpuMemoryMode: "auto" },
+ { ...LIVE, gpuLayers: 20 },
+ { ...LIVE, nCpuMoe: 0 },
+ { ...LIVE, selectedGpuIds: [0] },
+ ];
+ for (const changed of changes) {
+ assert.notEqual(modelConfigInstanceKey(MODEL, VARIANT, changed), base);
+ }
+ // The GPU pick is a set, not an order.
+ assert.equal(
+ modelConfigInstanceKey(MODEL, VARIANT, { ...LIVE, selectedGpuIds: [1, 0] }),
+ base,
+ );
+});
+
+test("the model and its quant still key the editor", () => {
+ const base = modelConfigInstanceKey(MODEL, VARIANT, LIVE);
+ assert.notEqual(modelConfigInstanceKey("unsloth/Other-GGUF", VARIANT, LIVE), base);
+ assert.notEqual(modelConfigInstanceKey(MODEL, "Q8_0", LIVE), base);
+ // A loose .gguf carries no quant; null and undefined are the same absence.
+ assert.equal(
+ modelConfigInstanceKey(MODEL, null, LIVE),
+ modelConfigInstanceKey(MODEL, undefined, LIVE),
+ );
+});
diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py
index 9cdf00a0c1..c1c4c02b5d 100644
--- a/tests/studio/test_model_picker_contracts.py
+++ b/tests/studio/test_model_picker_contracts.py
@@ -196,10 +196,21 @@ def test_active_model_config_round_trips_gpu_fields():
"features/hub/catalog/sampling-settings-dialog.tsx",
):
assert "useActiveModelConfig(" in _read(rel), rel
- signature = _read("features/model-picker/components/sidebar-model-config.tsx")
- assert "gpuFieldsSignature(config)" in signature
- shared = _read("features/model-picker/model-config/apply-per-model-config.ts")
+ # The GPU knobs are part of the editor's instance key, so a reload that lands
+ # on different placement re-seeds the editor instead of leaving it on the old
+ # values. Shared by every host that mounts ModelConfigPage.
+ shared = _read("features/model-picker/model-config/config-signature.ts")
assert "export function gpuFieldsSignature" in shared
+ assert "gpuFieldsSignature(config)," in shared
+ assert "export function modelConfigInstanceKey" in shared
+ for rel in (
+ "features/model-picker/components/sidebar-model-config.tsx",
+ "features/hub/catalog/hub-model-settings-view.tsx",
+ ):
+ assert "modelConfigInstanceKey(" in _read(rel), rel
+ # apply-per-model-config re-exports it, so its own callers are unchanged.
+ reexport = _read("features/model-picker/model-config/apply-per-model-config.ts")
+ assert "export { gpuFieldsSignature };" in reexport
def test_gpu_picker_round_trips_requested_pool_not_fitted_subset():
@@ -1012,8 +1023,7 @@ def test_the_hub_settings_page_matches_a_resident_path_loaded_model():
# The loadable identifier, as every other status reader records it. active_model
# is the clean public id, and two files sharing a filename collapse onto one, so
# storing it would let the wrong catalog row look loaded.
- assert "const checkpointId = resolveInferenceCheckpointId(status);" in hub
- assert "store.setCheckpoint(checkpointId, status.gguf_variant ?? null);" in hub
+ assert "checkpointId: resolveInferenceCheckpointId(status)," in hub
assert "setCheckpoint(status.active_model" not in hub
chat = " ".join(_read("features/chat/lib/apply-inference-status-to-store.ts").split())
assert "return status.model_identifier ?? status.active_model;" in chat, "the rule this mirrors"
@@ -1030,6 +1040,68 @@ def test_the_hub_settings_page_matches_a_resident_path_loaded_model():
assert "def public_model_id(" in backend, "the rule this mirrors"
+def test_the_hub_hydrates_the_live_settings_before_it_offers_them():
+ """The Hub builds activeModelConfig out of the chat runtime store, and landing
+ straight on /hub is the one entry point where nothing has applied
+ /api/inference/status yet: useChatModelRuntime has no mount sync and the chat
+ page is a different route. Pinning only the checkpoint left every other field
+ at its default, so the settings page passed those defaults on as the resident
+ model's live config and Apply reloaded the model with them."""
+ hub = " ".join(_read("features/hub/hub-page.tsx").split())
+ assert "adoptResidentModelStatus(" in hub
+ assert "applyActiveModelStatusToStore(status, {" in hub
+ assert "previousCheckpoint: previous.checkpoint ?? undefined," in hub
+ assert "previousGgufVariant: previous.ggufVariant," in hub
+ assert "modelLoading: store.modelLoading," in hub
+
+ adopt = " ".join(_read("features/hub/lib/adopt-inference-status.ts").split())
+ # Unconditional: a persisted checkpoint rehydrates from localStorage on its
+ # own, carrying none of the fields that say how the model was launched.
+ assert "actions.applyStatus(previous); return true;" in adopt
+ # Never fight the load that owns the store, and never describe an external
+ # provider's model with the resident GGUF's launch settings.
+ assert "if (state.checkpointIsExternal) { return false; }" in adopt
+ assert "if (state.modelLoading) { return false; }" in adopt
+
+ # Same call the chat runtime's own refresh makes, which is the rule this mirrors.
+ runtime = " ".join(_read("features/chat/hooks/use-chat-model-runtime.ts").split())
+ assert "applyActiveModelStatusToStore(statusRes, {" in runtime
+
+
+def test_the_hub_settings_editor_reseeds_when_the_live_config_lands():
+ """ModelConfigPage reads loadedConfig in a useState initializer, so it seeds
+ once per mounted instance. Opening the Hub's settings page before
+ /api/inference/status has hydrated (or while the target is still loading)
+ flips loadedConfig from null to the live config after mount, and without the
+ config in the React key the editor kept the saved/default values for a model
+ running with something else, which Apply then wrote back over it."""
+ view = " ".join(_read("features/hub/catalog/hub-model-settings-view.tsx").split())
+ assert "key={modelConfigInstanceKey( target.id, target.ggufVariant, loadedConfig, )}" in view
+ # Same key the sidebar entry uses; that parity is the point.
+ sidebar = " ".join(_read("features/model-picker/components/sidebar-model-config.tsx").split())
+ assert "key={modelConfigInstanceKey(modelId, ggufVariant, loadedConfig)}" in sidebar
+
+ signature = " ".join(
+ _read("features/model-picker/model-config/config-signature.ts").split()
+ )
+ # "No live config yet" has to be its own value: that transition is exactly
+ # the one that must remount.
+ assert 'if (!config) { return "none"; }' in signature
+ for field in (
+ "config.customContextLength",
+ "config.maxSeqLength",
+ "config.kvCacheDtype",
+ "config.speculativeType",
+ "config.specDraftNMax",
+ "config.tensorParallel",
+ "config.chatTemplateOverride",
+ ):
+ assert field in signature, field
+
+ page = " ".join(_read("features/model-picker/components/model-config-page.tsx").split())
+ assert "const [initial] = useState(resolveInitial);" in page, "the rule this mirrors"
+
+
def test_a_standalone_gguf_has_one_settings_key():
"""The inventory labels a single scanned .gguf from its filename, so the Hub row
menu keyed its settings to `:Q4_K_M` while the Chat picker, the detail