You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/07/28 11:41:21 UTC
(rocketmq-dashboard) 38/45: feat: add LLM settings endpoints (#506)
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
commit 29d493aa4ae68d99d0da498b40d423b8735a2709
Author: aias00 <li...@apache.org>
AuthorDate: Thu Jul 23 22:51:30 2026 -0700
feat: add LLM settings endpoints (#506)
Add LLM settings backend endpoints: config read/write, connectivity test, and model listing.
---
.../rocketmq/studio/ops/ai/LlmConfigService.java | 168 +++++++++++++++++++++
.../com/rocketmq/studio/ops/ai/LlmConfigVO.java | 40 +++++
.../com/rocketmq/studio/ops/ai/LlmController.java | 54 +++++++
.../com/rocketmq/studio/ops/ai/LlmModelItemVO.java | 30 ++++
.../rocketmq/studio/ops/ai/LlmModelsResultVO.java | 32 ++++
.../studio/ops/ai/LlmOperationResultVO.java | 39 +++++
.../studio/ops/ai/LlmConfigServiceTest.java | 129 ++++++++++++++++
.../rocketmq/studio/ops/ai/LlmControllerTest.java | 119 +++++++++++++++
8 files changed, 611 insertions(+)
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigService.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigService.java
new file mode 100644
index 00000000..2134f2b9
--- /dev/null
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigService.java
@@ -0,0 +1,168 @@
+/*
+ * 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.
+ */
+
+package com.rocketmq.studio.ops.ai;
+
+import com.rocketmq.studio.settings.GeneralSettingsVO;
+import com.rocketmq.studio.settings.SettingsService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class LlmConfigService {
+
+ private static final String OPENAI = "openai";
+ private static final Map<String, List<LlmModelItemVO>> PROVIDER_MODELS = Map.of(
+ OPENAI, List.of(
+ new LlmModelItemVO("gpt-4o", "GPT-4o"),
+ new LlmModelItemVO("gpt-4-turbo", "GPT-4 Turbo"),
+ new LlmModelItemVO("gpt-4", "GPT-4")),
+ "azure", List.of(
+ new LlmModelItemVO("gpt-4o", "GPT-4o"),
+ new LlmModelItemVO("gpt-4", "GPT-4")),
+ "deepseek", List.of(
+ new LlmModelItemVO("deepseek-chat", "DeepSeek Chat"),
+ new LlmModelItemVO("deepseek-reasoner", "DeepSeek Reasoner")),
+ "tongyi", List.of(
+ new LlmModelItemVO("qwen-max", "Qwen Max"),
+ new LlmModelItemVO("qwen-plus", "Qwen Plus"),
+ new LlmModelItemVO("qwen-turbo", "Qwen Turbo")),
+ "ollama", List.of(
+ new LlmModelItemVO("llama3", "Llama 3"),
+ new LlmModelItemVO("mistral", "Mistral"),
+ new LlmModelItemVO("qwen2.5", "Qwen 2.5")),
+ "bedrock", List.of(
+ new LlmModelItemVO("anthropic.claude-3-sonnet", "Claude 3 Sonnet"),
+ new LlmModelItemVO("anthropic.claude-3-haiku", "Claude 3 Haiku"),
+ new LlmModelItemVO("meta.llama3-70b", "Llama 3 70B")));
+
+ private final SettingsService settingsService;
+ private LlmConfigVO overrides;
+
+ public synchronized LlmConfigVO getConfig() {
+ if (overrides != null) {
+ return copy(overrides);
+ }
+ return fromGeneralSettings(settingsService.getGeneralSettings());
+ }
+
+ public synchronized void saveConfig(LlmConfigVO config) {
+ LlmConfigVO normalized = normalize(config);
+ overrides = copy(normalized);
+ GeneralSettingsVO current = settingsService.getGeneralSettings();
+ settingsService.saveGeneralSettings(GeneralSettingsVO.builder()
+ .theme(current.getTheme())
+ .compact(current.isCompact())
+ .desktopNotify(current.isDesktopNotify())
+ .notifySound(current.isNotifySound())
+ .sessionTimeout(current.getSessionTimeout())
+ .requireLogin(current.isRequireLogin())
+ .llmProvider(normalized.getProvider())
+ .apiKey(normalized.getApiKey())
+ .model(normalized.getModel())
+ .baseUrl(normalized.getApiBase())
+ .build());
+ }
+
+ public LlmOperationResultVO testConfig(LlmConfigVO config) {
+ LlmConfigVO normalized = normalize(config);
+ String provider = normalized.getProvider();
+ boolean keyRequired = !"ollama".equals(provider);
+ if (keyRequired && isBlank(normalized.getApiKey())) {
+ return LlmOperationResultVO.failure("API Key is required");
+ }
+ if ("azure".equals(provider) && isBlank(normalized.getDeploymentName())) {
+ return LlmOperationResultVO.failure("Deployment name is required");
+ }
+ if (isBlank(normalized.getModel())) {
+ return LlmOperationResultVO.failure("Model is required");
+ }
+ return LlmOperationResultVO.success("Configuration accepted");
+ }
+
+ public synchronized LlmModelsResultVO listModels() {
+ String provider = getConfig().getProvider();
+ List<LlmModelItemVO> models = PROVIDER_MODELS.getOrDefault(provider, PROVIDER_MODELS.get(OPENAI));
+ return new LlmModelsResultVO(0, models);
+ }
+
+ private LlmConfigVO fromGeneralSettings(GeneralSettingsVO settings) {
+ String provider = defaultString(settings.getLlmProvider(), OPENAI);
+ return LlmConfigVO.builder()
+ .provider(provider)
+ .apiKey(defaultString(settings.getApiKey(), ""))
+ .apiBase(defaultString(settings.getBaseUrl(), defaultApiBase(provider)))
+ .model(defaultString(settings.getModel(), defaultModel(provider)))
+ .maxTokens(4096)
+ .temperature(0.7)
+ .enabled(!isBlank(settings.getApiKey()))
+ .apiVersion("2024-02-15-preview")
+ .awsRegion("us-east-1")
+ .build();
+ }
+
+ private LlmConfigVO normalize(LlmConfigVO config) {
+ String provider = normalizeProvider(config == null ? null : config.getProvider());
+ return LlmConfigVO.builder()
+ .provider(provider)
+ .apiKey(defaultString(config == null ? null : config.getApiKey(), ""))
+ .apiBase(defaultString(config == null ? null : config.getApiBase(), defaultApiBase(provider)))
+ .model(defaultString(config == null ? null : config.getModel(), defaultModel(provider)))
+ .maxTokens(config == null || config.getMaxTokens() <= 0 ? 4096 : config.getMaxTokens())
+ .temperature(config == null ? 0.7 : config.getTemperature())
+ .enabled(config != null && config.isEnabled())
+ .deploymentName(defaultString(config == null ? null : config.getDeploymentName(), ""))
+ .apiVersion(defaultString(config == null ? null : config.getApiVersion(), "2024-02-15-preview"))
+ .awsRegion(defaultString(config == null ? null : config.getAwsRegion(), "us-east-1"))
+ .build();
+ }
+
+ private LlmConfigVO copy(LlmConfigVO config) {
+ return normalize(config);
+ }
+
+ private String normalizeProvider(String provider) {
+ String normalized = defaultString(provider, OPENAI).toLowerCase(Locale.ROOT);
+ return PROVIDER_MODELS.containsKey(normalized) ? normalized : OPENAI;
+ }
+
+ private String defaultModel(String provider) {
+ return PROVIDER_MODELS.getOrDefault(provider, PROVIDER_MODELS.get(OPENAI)).get(0).getId();
+ }
+
+ private String defaultApiBase(String provider) {
+ return switch (provider) {
+ case "deepseek" -> "https://api.deepseek.com/v1";
+ case "tongyi" -> "https://dashscope.aliyuncs.com/compatible-mode/v1";
+ case "ollama" -> "http://localhost:11434/v1";
+ default -> "https://api.openai.com/v1";
+ };
+ }
+
+ private String defaultString(String value, String fallback) {
+ return isBlank(value) ? fallback : value.trim();
+ }
+
+ private boolean isBlank(String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigVO.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigVO.java
new file mode 100644
index 00000000..85d6460c
--- /dev/null
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigVO.java
@@ -0,0 +1,40 @@
+/*
+ * 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.
+ */
+
+package com.rocketmq.studio.ops.ai;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class LlmConfigVO {
+ private String provider;
+ private String apiKey;
+ private String apiBase;
+ private String model;
+ private int maxTokens;
+ private double temperature;
+ private boolean enabled;
+ private String deploymentName;
+ private String apiVersion;
+ private String awsRegion;
+}
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmController.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmController.java
new file mode 100644
index 00000000..8f7e08bc
--- /dev/null
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmController.java
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+package com.rocketmq.studio.ops.ai;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/llm")
+@RequiredArgsConstructor
+public class LlmController {
+
+ private final LlmConfigService llmConfigService;
+
+ @GetMapping("/config")
+ public LlmConfigVO getConfig() {
+ return llmConfigService.getConfig();
+ }
+
+ @PostMapping("/config")
+ public LlmOperationResultVO saveConfig(@RequestBody LlmConfigVO config) {
+ llmConfigService.saveConfig(config);
+ return LlmOperationResultVO.success("saved");
+ }
+
+ @PostMapping("/config/test")
+ public LlmOperationResultVO testConfig(@RequestBody LlmConfigVO config) {
+ return llmConfigService.testConfig(config);
+ }
+
+ @GetMapping("/models")
+ public LlmModelsResultVO listModels() {
+ return llmConfigService.listModels();
+ }
+}
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelItemVO.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelItemVO.java
new file mode 100644
index 00000000..0a49758f
--- /dev/null
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelItemVO.java
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+
+package com.rocketmq.studio.ops.ai;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class LlmModelItemVO {
+ private String id;
+ private String name;
+}
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelsResultVO.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelsResultVO.java
new file mode 100644
index 00000000..0e95658f
--- /dev/null
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelsResultVO.java
@@ -0,0 +1,32 @@
+/*
+ * 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.
+ */
+
+package com.rocketmq.studio.ops.ai;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class LlmModelsResultVO {
+ private int status;
+ private List<LlmModelItemVO> data;
+}
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmOperationResultVO.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmOperationResultVO.java
new file mode 100644
index 00000000..2fcfebf6
--- /dev/null
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmOperationResultVO.java
@@ -0,0 +1,39 @@
+/*
+ * 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.
+ */
+
+package com.rocketmq.studio.ops.ai;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class LlmOperationResultVO {
+ private int status;
+ private String msg;
+ private String errMsg;
+
+ public static LlmOperationResultVO success(String message) {
+ return new LlmOperationResultVO(0, message, null);
+ }
+
+ public static LlmOperationResultVO failure(String message) {
+ return new LlmOperationResultVO(1, null, message);
+ }
+}
diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/LlmConfigServiceTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
new file mode 100644
index 00000000..a916c5dd
--- /dev/null
+++ b/server/src/test/java/com/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.
+ */
+
+package com.rocketmq.studio.ops.ai;
+
+import com.rocketmq.studio.settings.GeneralSettingsVO;
+import com.rocketmq.studio.settings.SettingsService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LlmConfigServiceTest {
+
+ private SettingsService settingsService;
+ private LlmConfigService llmConfigService;
+
+ @BeforeEach
+ void setUp() {
+ settingsService = mock(SettingsService.class);
+ when(settingsService.getGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+ .theme("dark")
+ .compact(true)
+ .desktopNotify(true)
+ .notifySound(false)
+ .sessionTimeout(45)
+ .requireLogin(true)
+ .llmProvider("openai")
+ .apiKey("sk-test")
+ .model("gpt-4o")
+ .baseUrl("https://api.openai.com/v1")
+ .build());
+ llmConfigService = new LlmConfigService(settingsService);
+ }
+
+ @Test
+ void getConfigShouldMapGeneralSettingsToLlmConfig() {
+ LlmConfigVO config = llmConfigService.getConfig();
+
+ assertThat(config.getProvider()).isEqualTo("openai");
+ assertThat(config.getApiKey()).isEqualTo("sk-test");
+ assertThat(config.getApiBase()).isEqualTo("https://api.openai.com/v1");
+ assertThat(config.getModel()).isEqualTo("gpt-4o");
+ assertThat(config.isEnabled()).isTrue();
+ }
+
+ @Test
+ void saveConfigShouldPreserveGeneralSettingsAndStoreLlmFields() {
+ LlmConfigVO config = LlmConfigVO.builder()
+ .provider("deepseek")
+ .apiKey("sk-deepseek")
+ .apiBase("https://api.deepseek.com/v1")
+ .model("deepseek-chat")
+ .maxTokens(8192)
+ .temperature(0.2)
+ .enabled(true)
+ .build();
+
+ llmConfigService.saveConfig(config);
+
+ ArgumentCaptor<GeneralSettingsVO> captor = ArgumentCaptor.forClass(GeneralSettingsVO.class);
+ verify(settingsService).saveGeneralSettings(captor.capture());
+ GeneralSettingsVO saved = captor.getValue();
+ assertThat(saved.getTheme()).isEqualTo("dark");
+ assertThat(saved.isCompact()).isTrue();
+ assertThat(saved.getLlmProvider()).isEqualTo("deepseek");
+ assertThat(saved.getApiKey()).isEqualTo("sk-deepseek");
+ assertThat(saved.getModel()).isEqualTo("deepseek-chat");
+ assertThat(saved.getBaseUrl()).isEqualTo("https://api.deepseek.com/v1");
+ assertThat(llmConfigService.getConfig().getProvider()).isEqualTo("deepseek");
+ }
+
+ @Test
+ void testConfigShouldRejectMissingRequiredApiKey() {
+ LlmOperationResultVO result = llmConfigService.testConfig(LlmConfigVO.builder()
+ .provider("openai")
+ .apiKey("")
+ .model("gpt-4o")
+ .build());
+
+ assertThat(result.getStatus()).isEqualTo(1);
+ assertThat(result.getErrMsg()).isEqualTo("API Key is required");
+ }
+
+ @Test
+ void testConfigShouldAllowOllamaWithoutApiKey() {
+ LlmOperationResultVO result = llmConfigService.testConfig(LlmConfigVO.builder()
+ .provider("ollama")
+ .apiBase("http://localhost:11434/v1")
+ .model("llama3")
+ .build());
+
+ assertThat(result.getStatus()).isZero();
+ assertThat(result.getMsg()).isEqualTo("Configuration accepted");
+ }
+
+ @Test
+ void listModelsShouldUseSavedProvider() {
+ llmConfigService.saveConfig(LlmConfigVO.builder()
+ .provider("tongyi")
+ .apiKey("dashscope-key")
+ .model("qwen-plus")
+ .enabled(true)
+ .build());
+
+ LlmModelsResultVO result = llmConfigService.listModels();
+
+ assertThat(result.getStatus()).isZero();
+ assertThat(result.getData()).extracting("id").contains("qwen-max", "qwen-plus");
+ }
+}
diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/LlmControllerTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/LlmControllerTest.java
new file mode 100644
index 00000000..e5b2d8fc
--- /dev/null
+++ b/server/src/test/java/com/rocketmq/studio/ops/ai/LlmControllerTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.
+ */
+
+package com.rocketmq.studio.ops.ai;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(LlmController.class)
+@AutoConfigureMockMvc(addFilters = false)
+class LlmControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockBean
+ private LlmConfigService llmConfigService;
+
+ @Test
+ void getConfigShouldReturnFrontendContractShape() throws Exception {
+ when(llmConfigService.getConfig()).thenReturn(LlmConfigVO.builder()
+ .provider("openai")
+ .apiKey("sk-test")
+ .apiBase("https://api.openai.com/v1")
+ .model("gpt-4o")
+ .maxTokens(4096)
+ .temperature(0.7)
+ .enabled(true)
+ .build());
+
+ mockMvc.perform(get("/api/llm/config"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.provider").value("openai"))
+ .andExpect(jsonPath("$.apiBase").value("https://api.openai.com/v1"))
+ .andExpect(jsonPath("$.model").value("gpt-4o"))
+ .andExpect(jsonPath("$.enabled").value(true));
+ }
+
+ @Test
+ void saveConfigShouldReturnStatusZero() throws Exception {
+ LlmConfigVO config = LlmConfigVO.builder()
+ .provider("openai")
+ .apiKey("sk-test")
+ .model("gpt-4o")
+ .enabled(true)
+ .build();
+
+ mockMvc.perform(post("/api/llm/config")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(config)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value(0))
+ .andExpect(jsonPath("$.msg").value("saved"));
+
+ verify(llmConfigService).saveConfig(any(LlmConfigVO.class));
+ }
+
+ @Test
+ void testConfigShouldReturnOperationResult() throws Exception {
+ when(llmConfigService.testConfig(any(LlmConfigVO.class)))
+ .thenReturn(LlmOperationResultVO.success("Configuration accepted"));
+
+ mockMvc.perform(post("/api/llm/config/test")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(LlmConfigVO.builder()
+ .provider("ollama")
+ .model("llama3")
+ .build())))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value(0))
+ .andExpect(jsonPath("$.msg").value("Configuration accepted"));
+ }
+
+ @Test
+ void listModelsShouldReturnStatusAndData() throws Exception {
+ when(llmConfigService.listModels()).thenReturn(new LlmModelsResultVO(0, List.of(
+ new LlmModelItemVO("gpt-4o", "GPT-4o"),
+ new LlmModelItemVO("gpt-4", "GPT-4"))));
+
+ mockMvc.perform(get("/api/llm/models"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value(0))
+ .andExpect(jsonPath("$.data[0].id").value("gpt-4o"))
+ .andExpect(jsonPath("$.data[1].name").value("GPT-4"));
+ }
+}