Skip to content

Commit d1d5a25

Browse files
authored
🤖 fix: gateway toggles + model LRU defaults (#1112)
Fixes three related UX issues: - Gateway toggles now persist correctly (remove double localStorage writes). - Model LRU merge guarantees newly-added defaults (e.g. openai:gpt-5.2) appear even when the list is full, evicting non-defaults first. - Settings 'Add model' provider dropdown no longer offers mux-gateway (plus a defensive guard). Tests: - Added hook tests for gateway toggles and LRU default inclusion. _Generated with `mux`_
1 parent c150c30 commit d1d5a25

File tree

5 files changed

+228
-31
lines changed

5 files changed

+228
-31
lines changed

src/browser/components/Settings/sections/ModelsSection.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export function ModelsSection() {
3636
const [newModel, setNewModel] = useState<NewModelForm>({ provider: "", modelId: "" });
3737
const [editing, setEditing] = useState<EditingState | null>(null);
3838
const [error, setError] = useState<string | null>(null);
39+
40+
const selectableProviders = SUPPORTED_PROVIDERS.filter(
41+
(provider) => !HIDDEN_PROVIDERS.has(provider)
42+
);
3943
const { defaultModel, setDefaultModel } = useModelLRU();
4044
const gateway = useGateway();
4145

@@ -52,6 +56,11 @@ export function ModelsSection() {
5256
const handleAddModel = useCallback(() => {
5357
if (!config || !newModel.provider || !newModel.modelId.trim()) return;
5458

59+
// mux-gateway is a routing layer, not a provider users should add models under.
60+
if (HIDDEN_PROVIDERS.has(newModel.provider)) {
61+
setError("Mux Gateway models can't be added directly. Enable Gateway per-model instead.");
62+
return;
63+
}
5564
const trimmedModelId = newModel.modelId.trim();
5665

5766
// Check for duplicates
@@ -184,9 +193,9 @@ export function ModelsSection() {
184193
<SelectValue placeholder="Provider" />
185194
</SelectTrigger>
186195
<SelectContent>
187-
{SUPPORTED_PROVIDERS.map((p) => (
188-
<SelectItem key={p} value={p}>
189-
{PROVIDER_DISPLAY_NAMES[p]}
196+
{selectableProviders.map((provider) => (
197+
<SelectItem key={provider} value={provider}>
198+
{PROVIDER_DISPLAY_NAMES[provider]}
190199
</SelectItem>
191200
))}
192201
</SelectContent>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Tests for useGateway hook
3+
*
4+
* Key invariant: clicking a gateway toggle should flip the persisted value exactly once.
5+
*
6+
* Regression being tested:
7+
* - The hook previously double-wrote to localStorage (usePersistedState + updatePersistedState),
8+
* causing the value to toggle twice and effectively become a no-op.
9+
*/
10+
11+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
12+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
13+
import { GlobalWindow } from "happy-dom";
14+
import { useGateway } from "./useGatewayModels";
15+
16+
const useProvidersConfigMock = mock(() => ({
17+
config: {
18+
"mux-gateway": {
19+
couponCodeSet: true,
20+
},
21+
},
22+
}));
23+
24+
void mock.module("@/browser/hooks/useProvidersConfig", () => ({
25+
useProvidersConfig: useProvidersConfigMock,
26+
}));
27+
28+
describe("useGateway", () => {
29+
beforeEach(() => {
30+
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
31+
globalThis.document = globalThis.window.document;
32+
globalThis.window.localStorage.clear();
33+
});
34+
35+
afterEach(() => {
36+
cleanup();
37+
globalThis.window = undefined as unknown as Window & typeof globalThis;
38+
globalThis.document = undefined as unknown as Document;
39+
});
40+
41+
test("toggleEnabled flips gateway-enabled once per call", async () => {
42+
const { result } = renderHook(() => useGateway());
43+
44+
await waitFor(() => expect(result.current.isConfigured).toBe(true));
45+
expect(result.current.isEnabled).toBe(true);
46+
47+
act(() => result.current.toggleEnabled());
48+
49+
expect(result.current.isEnabled).toBe(false);
50+
expect(globalThis.window.localStorage.getItem("gateway-enabled")).toBe("false");
51+
52+
act(() => result.current.toggleEnabled());
53+
54+
expect(result.current.isEnabled).toBe(true);
55+
expect(globalThis.window.localStorage.getItem("gateway-enabled")).toBe("true");
56+
});
57+
58+
test("toggleModelGateway flips gateway-models membership once per call", async () => {
59+
const { result } = renderHook(() => useGateway());
60+
61+
await waitFor(() => expect(result.current.isConfigured).toBe(true));
62+
63+
const modelId = "openai:gpt-5.2";
64+
65+
act(() => result.current.toggleModelGateway(modelId));
66+
67+
expect(result.current.modelUsesGateway(modelId)).toBe(true);
68+
expect(JSON.parse(globalThis.window.localStorage.getItem("gateway-models") ?? "[]")).toContain(
69+
modelId
70+
);
71+
72+
act(() => result.current.toggleModelGateway(modelId));
73+
74+
expect(result.current.modelUsesGateway(modelId)).toBe(false);
75+
expect(
76+
JSON.parse(globalThis.window.localStorage.getItem("gateway-models") ?? "[]")
77+
).not.toContain(modelId);
78+
});
79+
});

src/browser/hooks/useGatewayModels.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,9 @@ export function useGateway(): GatewayState {
165165
const isActive = isConfigured && isEnabled;
166166

167167
const toggleEnabled = useCallback(() => {
168+
// usePersistedState writes to localStorage synchronously.
169+
// Avoid double-writes here (which would toggle twice and become a no-op).
168170
setIsEnabled((prev) => !prev);
169-
// Also update localStorage synchronously
170-
updatePersistedState<boolean>(GATEWAY_ENABLED_KEY, (prev) => !prev);
171171
}, [setIsEnabled]);
172172

173173
const modelUsesGateway = useCallback(
@@ -177,15 +177,11 @@ export function useGateway(): GatewayState {
177177

178178
const toggleModelGateway = useCallback(
179179
(modelId: string) => {
180-
// Update React state for UI
180+
// usePersistedState writes to localStorage synchronously.
181+
// Avoid double-writes here (which would toggle twice and become a no-op).
181182
setEnabledModels((prev) =>
182183
prev.includes(modelId) ? prev.filter((m) => m !== modelId) : [...prev, modelId]
183184
);
184-
// Also update localStorage synchronously so toGatewayModel() sees it immediately
185-
// (usePersistedState batches writes in microtask, which can cause race conditions)
186-
updatePersistedState<string[]>(GATEWAY_MODELS_KEY, (prev) =>
187-
prev.includes(modelId) ? prev.filter((m) => m !== modelId) : [...prev, modelId]
188-
);
189185
},
190186
[setEnabledModels]
191187
);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Tests for useModelLRU hook
3+
*
4+
* Key invariant: newly-added DEFAULT_MODELS should show up in the selector even if the
5+
* persisted LRU list is already full.
6+
*/
7+
8+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
9+
import { cleanup, renderHook, waitFor } from "@testing-library/react";
10+
import { GlobalWindow } from "happy-dom";
11+
import { MODEL_ABBREVIATIONS } from "@/browser/utils/slashCommands/registry";
12+
import { defaultModel } from "@/common/utils/ai/models";
13+
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
14+
import { useModelLRU } from "./useModelLRU";
15+
16+
void mock.module("@/browser/contexts/API", () => ({
17+
useAPI: () => ({ api: null }),
18+
}));
19+
20+
describe("useModelLRU", () => {
21+
const MAX_LRU_SIZE = 12;
22+
23+
beforeEach(() => {
24+
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
25+
globalThis.document = globalThis.window.document;
26+
globalThis.window.localStorage.clear();
27+
});
28+
29+
afterEach(() => {
30+
cleanup();
31+
globalThis.window = undefined as unknown as Window & typeof globalThis;
32+
globalThis.document = undefined as unknown as Document;
33+
});
34+
35+
test("merges in missing defaults even when LRU is full", async () => {
36+
const FALLBACK_MODEL = WORKSPACE_DEFAULTS.model ?? defaultModel;
37+
const DEFAULT_MODELS = [
38+
FALLBACK_MODEL,
39+
...Array.from(new Set(Object.values(MODEL_ABBREVIATIONS))).filter(
40+
(m) => m !== FALLBACK_MODEL
41+
),
42+
].slice(0, MAX_LRU_SIZE);
43+
44+
// The bug report: openai:gpt-5.2 exists in Settings/KNOWN_MODELS but can be missing in the
45+
// chat creation selector when model-lru is already at max size.
46+
expect(DEFAULT_MODELS).toContain("openai:gpt-5.2");
47+
48+
const customModel1 = "openai:totally-custom-model";
49+
const customModel2 = "openai:totally-custom-model-2";
50+
51+
// Create a full list that *doesn't* contain openai:gpt-5.2.
52+
const seededBase = DEFAULT_MODELS.filter((m) => m !== "openai:gpt-5.2");
53+
const seeded = [...seededBase, customModel1, customModel2].slice(0, MAX_LRU_SIZE);
54+
55+
expect(seeded).toHaveLength(MAX_LRU_SIZE);
56+
expect(seeded).not.toContain("openai:gpt-5.2");
57+
expect(seeded).toContain(customModel2);
58+
59+
globalThis.window.localStorage.setItem("model-lru", JSON.stringify(seeded));
60+
61+
const { result } = renderHook(() => useModelLRU());
62+
63+
await waitFor(() => expect(result.current.recentModels).toContain("openai:gpt-5.2"));
64+
65+
// The newly-added default should be present, and we should stay within the cap.
66+
expect(result.current.recentModels.length).toBeLessThanOrEqual(MAX_LRU_SIZE);
67+
68+
const persisted = JSON.parse(
69+
globalThis.window.localStorage.getItem("model-lru") ?? "[]"
70+
) as string[];
71+
expect(persisted).toContain("openai:gpt-5.2");
72+
expect(persisted.length).toBeLessThanOrEqual(MAX_LRU_SIZE);
73+
74+
// To make room, we should evict a non-default model rather than dropping the new default.
75+
expect(persisted).not.toContain(customModel2);
76+
});
77+
});

src/browser/hooks/useModelLRU.ts

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,58 @@ const DEFAULT_MODELS = [
1717
...Array.from(new Set(Object.values(MODEL_ABBREVIATIONS))).filter((m) => m !== FALLBACK_MODEL),
1818
].slice(0, MAX_LRU_SIZE);
1919

20+
function mergeLRUWithDefaults(prev: string[]): string[] {
21+
// Migrate any mux-gateway:provider/model entries to canonical form
22+
const migrated = prev.map((m) => migrateGatewayModel(m));
23+
// Remove any remaining mux-gateway entries that couldn't be migrated
24+
const filtered = migrated.filter((m) => !isGatewayFormat(m));
25+
26+
// Deduplicate while preserving order (migration might create duplicates)
27+
const deduped: string[] = [];
28+
const seen = new Set<string>();
29+
for (const model of filtered) {
30+
if (seen.has(model)) {
31+
continue;
32+
}
33+
seen.add(model);
34+
deduped.push(model);
35+
}
36+
37+
// Ensure defaults are present. If the list is already full, evict non-default models
38+
// (least-recent from the end) to make room for any newly added defaults.
39+
const merged = [...deduped];
40+
for (const defaultModel of DEFAULT_MODELS) {
41+
if (!merged.includes(defaultModel)) {
42+
merged.push(defaultModel);
43+
}
44+
}
45+
46+
if (merged.length <= MAX_LRU_SIZE) {
47+
return merged;
48+
}
49+
50+
const defaultSet = new Set(DEFAULT_MODELS);
51+
while (merged.length > MAX_LRU_SIZE) {
52+
let idxToRemove = -1;
53+
for (let i = merged.length - 1; i >= 0; i--) {
54+
if (!defaultSet.has(merged[i])) {
55+
idxToRemove = i;
56+
break;
57+
}
58+
}
59+
60+
// If everything left is a default model, fall back to truncation (should not happen
61+
// since DEFAULT_MODELS is capped at MAX_LRU_SIZE).
62+
if (idxToRemove === -1) {
63+
return merged.slice(0, MAX_LRU_SIZE);
64+
}
65+
66+
merged.splice(idxToRemove, 1);
67+
}
68+
69+
return merged;
70+
}
71+
2072
function persistModels(models: string[]): void {
2173
updatePersistedState(LRU_KEY, models.slice(0, MAX_LRU_SIZE));
2274
}
@@ -63,27 +115,11 @@ export function useModelLRU() {
63115
{ listener: true }
64116
);
65117

66-
// Merge any new defaults from MODEL_ABBREVIATIONS and migrate legacy gateway models (only once on mount)
118+
// Merge any new defaults from MODEL_ABBREVIATIONS and migrate legacy gateway models.
119+
// This runs once on mount and guarantees new defaults show up even if the persisted list is full.
67120
useEffect(() => {
68-
setRecentModels((prev) => {
69-
// Migrate any mux-gateway:provider/model entries to canonical form
70-
const migrated = prev.map((m) => migrateGatewayModel(m));
71-
// Remove any remaining mux-gateway entries that couldn't be migrated
72-
const filtered = migrated.filter((m) => !isGatewayFormat(m));
73-
// Deduplicate (migration might create duplicates)
74-
const deduped = [...new Set(filtered)];
75-
76-
// Merge defaults
77-
const merged = [...deduped];
78-
for (const defaultModel of DEFAULT_MODELS) {
79-
if (!merged.includes(defaultModel)) {
80-
merged.push(defaultModel);
81-
}
82-
}
83-
return merged.slice(0, MAX_LRU_SIZE);
84-
});
85-
// eslint-disable-next-line react-hooks/exhaustive-deps
86-
}, []); // Only run once on mount
121+
setRecentModels((prev) => mergeLRUWithDefaults(prev));
122+
}, [setRecentModels]);
87123

88124
// Fetch custom models from providers config
89125
useEffect(() => {

0 commit comments

Comments
 (0)