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)');
+ });
+});