This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 46b03bbc fix: prevent stale LLM model responses from overriding 
providers (#737)
46b03bbc is described below

commit 46b03bbcc7cae42cdde6191beeda88f8c8f5e738
Author: Rui <[email protected]>
AuthorDate: Mon Aug 3 11:16:45 2026 +0800

    fix: prevent stale LLM model responses from overriding providers (#737)
---
 web/src/pages/studio/LlmSettings.tsx               | 218 ++++++++++++++------
 .../__tests__/LlmSettingsAsyncState.test.tsx       | 219 +++++++++++++++++++++
 2 files changed, 377 insertions(+), 60 deletions(-)

diff --git a/web/src/pages/studio/LlmSettings.tsx 
b/web/src/pages/studio/LlmSettings.tsx
index 76b154c3..22e2b522 100644
--- a/web/src/pages/studio/LlmSettings.tsx
+++ b/web/src/pages/studio/LlmSettings.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useState, useRef, useCallback } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import {
   Form,
   Input,
@@ -156,19 +156,34 @@ const LlmSettingsPage: React.FC = () => {
   const [modelOptions, setModelOptions] = useState<{ value: string; label: 
string }[]>([]);
   const [modelsLoading, setModelsLoading] = useState(false);
 
-  const initialized = useRef<boolean | null>(null);
-  if (initialized.current == null) {
-    initialized.current = true;
-    fetchConfig();
-    fetchModels('openai');
-  }
-
-  function fetchConfig() {
-    setLoading(true);
-    getLlmConfig()
-      .then((config) => {
+  const mountedRef = useRef(false);
+  const lifecycleGeneration = useRef(0);
+  const providerInteractionGeneration = useRef(0);
+  const configRequestGeneration = useRef(0);
+  const selectedProviderRef = useRef('openai');
+  const modelRequestGeneration = useRef(0);
+
+  const fetchConfig = useCallback(
+    async (
+      configPromise: Promise<LlmConfig>,
+      requestLifecycle: number,
+      requestGeneration: number,
+      requestProviderGeneration: number,
+    ) => {
+      const ownsRequest = () =>
+        mountedRef.current &&
+        requestLifecycle === lifecycleGeneration.current &&
+        requestGeneration === configRequestGeneration.current &&
+        requestProviderGeneration === providerInteractionGeneration.current;
+      if (ownsRequest()) {
+        setLoading(true);
+      }
+      try {
+        const config = await configPromise;
+        if (!ownsRequest()) return;
         if (config) {
           const provider = config.provider || 'openai';
+          selectedProviderRef.current = provider;
           setSelectedProvider(provider);
           setEnabled(config.enabled || false);
           if (config.apiKeyConfigured) {
@@ -187,54 +202,127 @@ const LlmSettingsPage: React.FC = () => {
             awsRegion: config.awsRegion || 'us-east-1',
           });
         }
-      })
-      .catch(() => {
-        message.error(t('llm.loadFailed'));
-      })
-      .finally(() => {
-        setLoading(false);
-      });
-  }
-
-  function fetchModels(providerOverride?: string, modelOverride?: string) {
-    setModelsLoading(true);
-    getLlmConfig()
-      .then((config) => {
-        const provider = providerOverride || config?.provider || 'openai';
-        const model = modelOverride || config?.model || '';
+        return config;
+      } catch {
+        if (ownsRequest()) {
+          message.error(t('llm.loadFailed'));
+        }
+      } finally {
+        if (ownsRequest()) {
+          setLoading(false);
+        }
+      }
+    },
+    [form, message, t],
+  );
+
+  const fetchModels = useCallback(
+    async (
+      providerOverride?: string,
+      modelOverride?: string,
+      expectedProviderGeneration = providerInteractionGeneration.current,
+    ) => {
+      if (!mountedRef.current) return;
+      const requestedProvider = providerOverride || 
selectedProviderRef.current;
+      if (
+        requestedProvider !== selectedProviderRef.current ||
+        expectedProviderGeneration !== providerInteractionGeneration.current
+      ) {
+        return;
+      }
+      const requestLifecycle = lifecycleGeneration.current;
+      const requestGeneration = ++modelRequestGeneration.current;
+      const ownsRequest = () =>
+        mountedRef.current &&
+        requestLifecycle === lifecycleGeneration.current &&
+        requestGeneration === modelRequestGeneration.current &&
+        expectedProviderGeneration === providerInteractionGeneration.current &&
+        requestedProvider === selectedProviderRef.current;
+      let model = modelOverride;
+
+      setModelsLoading(true);
+      try {
+        const config = await getLlmConfig();
+        if (!ownsRequest()) return;
+
+        const configuredProvider = config?.provider || requestedProvider;
+        if (configuredProvider !== requestedProvider) return;
+
+        model = modelOverride || config?.model || '';
         if (!config || !config.enabled) {
-          setModelOptions(fallbackModelOptions(provider, model));
+          setModelOptions(fallbackModelOptions(requestedProvider, model));
           return;
         }
-        getLlmModels()
-          .then((result) => {
-            let models: string[] = [];
-            if (result && result.status === 0 && result.data) {
-              models = result.data.map((m) => m.id || m.name || 
'').filter(Boolean);
-            }
-            if (result?.source === 'fallback') {
-              message.warning(
-                result.hint ||
-                  result.warning ||
-                  '已回退到内置模型列表,请检查 Provider 凭证或模型接口。',
-              );
-            }
-            if (models.length === 0) {
-              models = fallbackModelOptions(provider, 
config.model).map((option) => option.value);
-            }
-            setModelOptions(models.map((m) => ({ value: m, label: m })));
-          })
-          .catch(() => {
-            setModelOptions(fallbackModelOptions(provider, model));
-          });
-      })
-      .catch(() => {
-        setModelOptions(fallbackModelOptions(providerOverride || 
selectedProvider, modelOverride));
-      })
-      .finally(() => {
-        setModelsLoading(false);
+
+        const result = await getLlmModels();
+        if (!ownsRequest()) return;
+
+        let models: string[] = [];
+        if (result && result.status === 0 && result.data) {
+          models = result.data.map((m) => m.id || m.name || 
'').filter(Boolean);
+        }
+        if (result?.source === 'fallback') {
+          message.warning(
+            result.hint ||
+              result.warning ||
+              '已回退到内置模型列表,请检查 Provider 凭证或模型接口。',
+          );
+        }
+        if (models.length === 0) {
+          models = fallbackModelOptions(requestedProvider, config.model).map(
+            (option) => option.value,
+          );
+        }
+        setModelOptions(models.map((item) => ({ value: item, label: item })));
+      } catch {
+        if (ownsRequest()) {
+          setModelOptions(fallbackModelOptions(requestedProvider, model));
+        }
+      } finally {
+        if (ownsRequest()) {
+          setModelsLoading(false);
+        }
+      }
+    },
+    [message],
+  );
+
+  useEffect(() => {
+    const currentLifecycle = ++lifecycleGeneration.current;
+    const currentConfigRequest = ++configRequestGeneration.current;
+    const currentProviderGeneration = providerInteractionGeneration.current;
+    mountedRef.current = true;
+    const configPromise = getLlmConfig();
+    queueMicrotask(() => {
+      void fetchConfig(
+        configPromise,
+        currentLifecycle,
+        currentConfigRequest,
+        currentProviderGeneration,
+      ).then((config) => {
+        if (
+          !mountedRef.current ||
+          currentLifecycle !== lifecycleGeneration.current ||
+          currentConfigRequest !== configRequestGeneration.current ||
+          currentProviderGeneration !== providerInteractionGeneration.current
+        ) {
+          return;
+        }
+        void fetchModels(
+          config?.provider || selectedProviderRef.current,
+          config?.model,
+          currentProviderGeneration,
+        );
       });
-  }
+    });
+
+    return () => {
+      mountedRef.current = false;
+      lifecycleGeneration.current += 1;
+      configRequestGeneration.current += 1;
+      modelRequestGeneration.current += 1;
+    };
+  }, [fetchConfig, fetchModels]);
 
   const maskApiKey = (key: string) => {
     if (key === MASKED_API_KEY) return MASKED_API_KEY;
@@ -255,7 +343,15 @@ const LlmSettingsPage: React.FC = () => {
 
   const handleProviderChange = useCallback(
     (value: string) => {
-      const providerChanged = value !== selectedProvider;
+      const providerChanged = value !== selectedProviderRef.current;
+      selectedProviderRef.current = value;
+      if (providerChanged) {
+        providerInteractionGeneration.current += 1;
+        configRequestGeneration.current += 1;
+        modelRequestGeneration.current += 1;
+        setLoading(false);
+        setModelsLoading(false);
+      }
       setSelectedProvider(value);
       setTestResult(null);
       const provider = PROVIDERS.find((p) => p.key === value);
@@ -272,7 +368,7 @@ const LlmSettingsPage: React.FC = () => {
         }
       }
     },
-    [form, selectedProvider],
+    [form],
   );
 
   const handleApiKeyFocus = () => {
@@ -291,6 +387,7 @@ const LlmSettingsPage: React.FC = () => {
   };
 
   const handleTestConnection = () => {
+    const requestProviderGeneration = providerInteractionGeneration.current;
     setTestLoading(true);
     setTestResult(null);
     form
@@ -310,7 +407,7 @@ const LlmSettingsPage: React.FC = () => {
                     form.setFieldsValue({ apiKey: MASKED_API_KEY });
                     setApiKeyMasked(true);
                   }
-                  fetchModels(testConfig.provider, testConfig.model);
+                  fetchModels(testConfig.provider, testConfig.model, 
requestProviderGeneration);
                 })
                 .catch(() => {
                   // auto-save failure is non-critical
@@ -337,6 +434,7 @@ const LlmSettingsPage: React.FC = () => {
   };
 
   const handleSave = () => {
+    const requestProviderGeneration = providerInteractionGeneration.current;
     setLoading(true);
     form
       .validateFields()
@@ -351,7 +449,7 @@ const LlmSettingsPage: React.FC = () => {
                 form.setFieldsValue({ apiKey: MASKED_API_KEY });
                 setApiKeyMasked(true);
               }
-              fetchModels(config.provider, config.model);
+              fetchModels(config.provider, config.model, 
requestProviderGeneration);
             } else {
               message.error((result && result.errMsg) || t('llm.saveFailed'));
             }
diff --git a/web/src/pages/studio/__tests__/LlmSettingsAsyncState.test.tsx 
b/web/src/pages/studio/__tests__/LlmSettingsAsyncState.test.tsx
new file mode 100644
index 00000000..62290edc
--- /dev/null
+++ b/web/src/pages/studio/__tests__/LlmSettingsAsyncState.test.tsx
@@ -0,0 +1,219 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { App } from 'antd';
+import { act, fireEvent, render, screen, waitFor } from 
'@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { StrictMode } from 'react';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { LlmModelsResult } from '../../../api/llm';
+import { LangProvider } from '../../../i18n/LangContext';
+import LlmSettingsPage from '../LlmSettings';
+
+const apiMocks = vi.hoisted(() => ({
+  getLlmConfig: vi.fn(),
+  getLlmModels: vi.fn(),
+  saveLlmConfig: vi.fn(),
+  testLlmConnection: vi.fn(),
+}));
+
+vi.mock('../../../api/llm', () => apiMocks);
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+const createDeferred = <T,>() => {
+  let resolve!: (value: T) => void;
+  let reject!: (reason?: unknown) => void;
+  const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+    resolve = resolvePromise;
+    reject = rejectPromise;
+  });
+  return { promise, resolve, reject };
+};
+
+const OPENAI_CONFIG = {
+  provider: 'openai',
+  apiBase: 'https://api.openai.com/v1',
+  model: 'gpt-4o',
+  maxTokens: 4096,
+  temperature: 0.7,
+  enabled: true,
+  apiKeyConfigured: true,
+};
+
+const renderPage = (strict = false) => {
+  const page = (
+    <App>
+      <LangProvider>
+        <LlmSettingsPage />
+      </LangProvider>
+    </App>
+  );
+  return render(strict ? <StrictMode>{page}</StrictMode> : page);
+};
+
+describe('LlmSettingsPage async request ownership', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    apiMocks.getLlmConfig.mockResolvedValue(OPENAI_CONFIG);
+    apiMocks.getLlmModels.mockResolvedValue({
+      status: 0,
+      data: [{ id: 'gpt-4o' }],
+    });
+    apiMocks.saveLlmConfig.mockResolvedValue({ status: 0 });
+    apiMocks.testLlmConnection.mockResolvedValue({ status: 0 });
+  });
+
+  it('does not replace a newly selected provider model list with an older 
response', async () => {
+    const oldProviderModels = createDeferred<LlmModelsResult>();
+    apiMocks.getLlmModels.mockReturnValue(oldProviderModels.promise);
+    const user = userEvent.setup();
+    renderPage();
+
+    await waitFor(() => 
expect(apiMocks.getLlmModels).toHaveBeenCalledTimes(1));
+    await user.click(screen.getByText('DeepSeek', { selector: 'div' }));
+    expect(await screen.findByText('deepseek-chat')).toBeInTheDocument();
+
+    await act(async () => {
+      oldProviderModels.resolve({
+        status: 0,
+        data: [{ id: 'openai-only-late-model' }],
+      });
+    });
+
+    await user.click(screen.getByRole('combobox'));
+    expect(
+      screen.queryByText('openai-only-late-model', {
+        selector: '.ant-select-item-option-content',
+      }),
+    ).not.toBeInTheDocument();
+    expect(
+      await screen.findByText('deepseek-reasoner', {
+        selector: '.ant-select-item-option-content',
+      }),
+    ).toBeInTheDocument();
+  });
+
+  it('keeps the model selector loading while the provider model request is 
pending', async () => {
+    const providerModels = createDeferred<LlmModelsResult>();
+    apiMocks.getLlmModels.mockReturnValue(providerModels.promise);
+    renderPage();
+
+    await waitFor(() => 
expect(apiMocks.getLlmModels).toHaveBeenCalledTimes(1));
+
+    
expect(screen.getByRole('combobox').closest('.ant-select')).toHaveClass('ant-select-loading');
+  });
+
+  it('does not let a delayed initial config replace a provider selected after 
request start', async () => {
+    const initialConfig = createDeferred<typeof OPENAI_CONFIG>();
+    apiMocks.getLlmConfig.mockReturnValueOnce(initialConfig.promise);
+    renderPage();
+
+    expect(apiMocks.getLlmConfig).toHaveBeenCalledTimes(1);
+    fireEvent.click(screen.getByText('DeepSeek', { selector: 'div' }));
+    expect(screen.getByRole('textbox', { name: 'API Base URL' })).toHaveValue(
+      'https://api.deepseek.com/v1',
+    );
+
+    await act(async () => {
+      initialConfig.resolve(OPENAI_CONFIG);
+    });
+
+    expect(screen.getByRole('textbox', { name: 'API Base URL' })).toHaveValue(
+      'https://api.deepseek.com/v1',
+    );
+    expect(
+      screen.getByText('DeepSeek', { selector: 'div' 
}).parentElement?.parentElement,
+    ).toHaveStyle('border: 2px solid rgb(77, 107, 254)');
+    expect(apiMocks.getLlmModels).not.toHaveBeenCalled();
+  });
+
+  it('does not start an old-provider model request after an earlier connection 
test completes', async () => {
+    const oldProviderTest = createDeferred<{ status: number }>();
+    apiMocks.testLlmConnection.mockReturnValue(oldProviderTest.promise);
+    apiMocks.getLlmModels
+      .mockResolvedValueOnce({ status: 0, data: [{ id: 'initial-openai-model' 
}] })
+      .mockResolvedValueOnce({ status: 0, data: [{ id: 'late-openai-model' }] 
});
+    const user = userEvent.setup();
+    renderPage();
+
+    await waitFor(() => 
expect(apiMocks.getLlmModels).toHaveBeenCalledTimes(1));
+    await user.click(screen.getByRole('button', { name: '连接测试' }));
+    await waitFor(() => 
expect(apiMocks.testLlmConnection).toHaveBeenCalledTimes(1));
+    await user.click(screen.getByText('DeepSeek', { selector: 'div' }));
+    expect(await screen.findByText('deepseek-chat')).toBeInTheDocument();
+
+    await act(async () => {
+      oldProviderTest.resolve({ status: 0 });
+    });
+    await waitFor(() => 
expect(apiMocks.saveLlmConfig).toHaveBeenCalledTimes(1));
+
+    await user.click(screen.getByRole('combobox'));
+    expect(
+      screen.queryByText('late-openai-model', {
+        selector: '.ant-select-item-option-content',
+      }),
+    ).not.toBeInTheDocument();
+    expect(
+      await screen.findByText('deepseek-reasoner', {
+        selector: '.ant-select-item-option-content',
+      }),
+    ).toBeInTheDocument();
+    expect(apiMocks.getLlmModels).toHaveBeenCalledTimes(1);
+  });
+
+  it('ignores configuration from the discarded StrictMode lifecycle', async () 
=> {
+    const discardedConfig = createDeferred<typeof OPENAI_CONFIG>();
+    apiMocks.getLlmConfig
+      .mockReturnValueOnce(discardedConfig.promise)
+      .mockResolvedValue(OPENAI_CONFIG);
+    renderPage(true);
+
+    await waitFor(() => 
expect(apiMocks.getLlmModels).toHaveBeenCalledTimes(1));
+
+    await act(async () => {
+      discardedConfig.resolve({
+        ...OPENAI_CONFIG,
+        provider: 'deepseek',
+        apiBase: 'https://api.deepseek.com/v1',
+        model: 'deepseek-chat',
+      });
+    });
+
+    expect(apiMocks.getLlmModels).toHaveBeenCalledTimes(1);
+    expect(screen.getByRole('textbox', { name: 'API Base URL' })).toHaveValue(
+      'https://api.openai.com/v1',
+    );
+    expect(
+      screen.getByText('OpenAI', { selector: 'div' 
}).parentElement?.parentElement,
+    ).toHaveStyle('border: 2px solid rgb(16, 163, 127)');
+  });
+});

Reply via email to