diff --git a/studio/frontend/src/features/model-picker/api/model-overrides.ts b/studio/frontend/src/features/model-picker/api/model-overrides.ts index d4e89985b4..bf8ad1aa58 100644 --- a/studio/frontend/src/features/model-picker/api/model-overrides.ts +++ b/studio/frontend/src/features/model-picker/api/model-overrides.ts @@ -123,10 +123,41 @@ function toApiOverride(config: PerModelConfig | null): ApiModelOverride { return payload; } +// One in-flight write per model, so writes for the same model commit in the +// order they were issued. Saving twice quickly, or saving while the one-time +// backfill is still running, otherwise leaves two independent requests racing: +// the older response can land last and resurrect the entry the newer one meant +// to replace or remove, and an API load then applies settings the user has +// already changed. Different models still overlap. +const writesByKey = new Map>(); + export async function putModelOverride( modelId: string, ggufVariant: string | null | undefined, config: PerModelConfig | null, +): Promise { + const key = modelOverrideKey(modelId, ggufVariant); + // Chain on the settled tail: a failed write must not cancel the next one. + const previous = writesByKey.get(key) ?? Promise.resolve(); + const write = previous + .catch(() => {}) + .then(() => sendModelOverride(modelId, ggufVariant, config)); + writesByKey.set(key, write); + try { + await write; + } finally { + // Only the last writer clears the slot, so a queue that is still building + // keeps its ordering. + if (writesByKey.get(key) === write) { + writesByKey.delete(key); + } + } +} + +async function sendModelOverride( + modelId: string, + ggufVariant: string | null | undefined, + config: PerModelConfig | null, ): Promise { const res = await authFetch(OVERRIDES_URL, { method: "PUT", diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index f146de5c0f..a22ee1f5c3 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -763,3 +763,20 @@ def test_monitor_overlay_does_not_pull_in_the_lazy_page(): shared = _read("features/api-monitor/lifecycle.ts") assert "export function isLifecycleEntry(" in shared assert "export function lifecycleLabel(" in shared + + +def test_override_writes_are_ordered_per_model(): + """Two saves for one model, or a save racing the one-time backfill, started + independent requests with no sequencing, so the older response could commit + last and resurrect the entry the newer one meant to replace. An API load + then applies settings the user has already changed. Different models still + overlap, so a slow write for one cannot hold up another. + """ + src = " ".join(_read("features/model-picker/api/model-overrides.ts").split()) + assert "const writesByKey = new Map>();" in src + # Keyed by the same override key the server stores under. + assert "const key = modelOverrideKey(modelId, ggufVariant);" in src + # Chained on the settled tail, so one failed write cannot cancel the next. + assert "previous .catch(() => {}) .then(() => sendModelOverride(" in src + # Only the last writer clears the slot, or a queue still building loses order. + assert "if (writesByKey.get(key) === write) { writesByKey.delete(key); }" in src