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