Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions gui/src/forms/AddModelForm.dynamicFetchRace.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { act, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { AddModelForm } from "./AddModelForm";
import { renderWithProviders } from "../util/test/render";

const fetchProviderModelsMock = vi.hoisted(() => vi.fn());
const initializeDynamicModelsMock = vi.hoisted(() => vi.fn(async () => {}));

vi.mock("../pages/AddNewModel/configs/fetchProviderModels", () => ({
fetchProviderModels: fetchProviderModelsMock,
initializeDynamicModels: initializeDynamicModelsMock,
}));

vi.mock("../components/modelSelection/ModelSelectionListbox", () => ({
default: ({
selectedProvider,
setSelectedProvider,
topOptions = [],
otherOptions = [],
searchPlaceholder,
}: any) => (
<div data-testid={searchPlaceholder ? "provider-listbox" : "model-listbox"}>
<div
data-testid={searchPlaceholder ? "provider-current" : "model-current"}
>
{selectedProvider.title}
</div>
<div>
{topOptions.map((option: any) => (
<button
key={option.title}
type="button"
onClick={() => setSelectedProvider(option)}
>
{option.title}
</button>
))}
</div>
<div>
{otherOptions.map((option: any) => (
<div key={option.title} data-testid="model-other-option">
{option.title}
</div>
))}
</div>
</div>
),
}));

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}

function makeFetchedModel(title: string) {
return {
title,
description: title,
params: {
title,
model: title.toLowerCase().replace(/\s+/g, "-"),
},
isOpenSource: false,
providerOptions: ["openai"],
};
}

describe("AddModelForm dynamic fetch race", () => {
beforeEach(() => {
fetchProviderModelsMock.mockReset();
initializeDynamicModelsMock.mockClear();
});

it("does not leak stale models from the previous provider if the earlier fetch resolves late", async () => {
const pendingOpenAiFetch = deferred<any[]>();

fetchProviderModelsMock.mockImplementation(
(_messenger: unknown, provider: string) => {
if (provider === "openai") {
return pendingOpenAiFetch.promise;
}
return Promise.resolve([]);
},
);

const { user } = await renderWithProviders(
<AddModelForm onDone={vi.fn()} />,
);

await user.type(
screen.getByPlaceholderText(/Enter your OpenAI API key/i),
"sk-test-key",
);
await user.click(screen.getByTitle(/fetch available models/i));

await user.click(screen.getByRole("button", { name: "Anthropic" }));
expect(screen.getByTestId("provider-current")).toHaveTextContent(
"Anthropic",
);

await act(async () => {
pendingOpenAiFetch.resolve([makeFetchedModel("OpenAI Dynamic Model")]);
await pendingOpenAiFetch.promise;
});

await waitFor(() => {
expect(screen.getByTestId("provider-current")).toHaveTextContent(
"Anthropic",
);
expect(screen.getByTestId("model-listbox")).not.toHaveTextContent(
"OpenAI Dynamic Model",
);
});
});

it("clears the previous provider API key before a newly selected keyed provider can fetch models", async () => {
fetchProviderModelsMock.mockResolvedValue([]);

const { user } = await renderWithProviders(
<AddModelForm onDone={vi.fn()} />,
);

const apiKeyInput = screen.getByPlaceholderText(
/Enter your OpenAI API key/i,
);
await user.type(apiKeyInput, "sk-openai-secret");
expect(apiKeyInput).toHaveValue("sk-openai-secret");

await user.click(screen.getByRole("button", { name: "Anthropic" }));

const anthropicApiKeyInput = screen.getByPlaceholderText(
/Enter your Anthropic API key/i,
);
expect(anthropicApiKeyInput).toHaveValue("");

const fetchButton = screen.getByTitle(/fetch available models/i);
await user.click(fetchButton);

expect(fetchProviderModelsMock).not.toHaveBeenCalled();
});
});
14 changes: 7 additions & 7 deletions gui/src/forms/AddModelForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
ArrowPathIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline";
import { useCallback, useContext, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Button, Input, StyledActionButton } from "../components";
import Alert from "../components/gui/Alert";
Expand Down Expand Up @@ -49,6 +49,7 @@ export function AddModelForm({
[],
);
const [isFetchingModels, setIsFetchingModels] = useState(false);
const selectedProviderRef = useRef(selectedProvider.provider);

useEffect(() => {
void initializeDynamicModels(ideMessenger);
Expand All @@ -72,9 +73,9 @@ export function AddModelForm({
apiKey,
apiBase,
);
setFetchedModelsList((prev) =>
selectedProvider.provider === providerAtFetchTime ? models : prev,
);
if (selectedProviderRef.current === providerAtFetchTime) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Stale fetch results can be applied again after switching away and back to the same provider because the response is guarded only by provider identity, not the request generation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gui/src/forms/AddModelForm.tsx, line 81:

<comment>Stale fetch results can be applied again after switching away and back to the same provider because the response is guarded only by provider identity, not the request generation.</comment>

<file context>
@@ -72,13 +78,15 @@ export function AddModelForm({
-      setFetchedModelsList((prev) =>
-        selectedProvider.provider === providerAtFetchTime ? models : prev,
-      );
+      if (selectedProviderRef.current === providerAtFetchTime) {
+        setFetchedModelsList(models);
+      }
</file context>
Suggested change
if (selectedProviderRef.current === providerAtFetchTime) {
if (
selectedProviderRef.current === providerAtFetchTime &&
fetchGenerationRef.current === fetchGeneration
) {
Fix with Cubic

setFetchedModelsList(models);
Comment on lines +81 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard fetched model updates by request generation

This update only checks selectedProviderRef before applying results, so two concurrent fetches for the same provider can still race: if a user clicks refresh twice (or changes API key/API base and refetches), the older request may resolve last and overwrite the newer model list. That reintroduces stale models in the picker even though fetchGenerationRef is already tracked; the generation check should also gate setFetchedModelsList.

Useful? React with 👍 / 👎.

}
} catch (error) {
console.error("Failed to fetch models:", error);
} finally {
Expand Down Expand Up @@ -128,9 +129,7 @@ export function AddModelForm({

useEffect(() => {
setSelectedModel(selectedProvider.packages[0]);
if (!selectedProvider.tags?.includes(ModelProviderTags.RequiresApiKey)) {
formMethods.setValue("apiKey", "");
}
formMethods.setValue("apiKey", "");
Comment thread
shaun0927 marked this conversation as resolved.
}, [selectedProvider]);

const requiresSkPrefix =
Expand Down Expand Up @@ -203,6 +202,7 @@ export function AddModelForm({
(provider) => provider.title === val.title,
);
if (match) {
selectedProviderRef.current = match.provider;
setSelectedProvider(match);
}
}}
Expand Down
Loading