package vip.mate.llm.chatmodel; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.openai.OpenAiChatOptions; import reactor.core.publisher.Flux; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelProviderEntity; import vip.mate.llm.service.ModelProviderService; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; /** * Regression coverage for {@link OpenAiCompatibleChatModelBuilder#buildOpenAiOptions} * forwarding unrecognized top-level {@code generateKwargs} keys into * {@link OpenAiChatOptions#getExtraBody()} via * {@link ProviderGenerateKwargs#collectPassthroughExtraBody}. * *

Locks in the fix: previously, an admin-configured key like vLLM's * {@code chat_template_kwargs} (used to disable Qwen thinking mode) was silently * dropped because {@code buildOpenAiOptions} only read a fixed allow-list of * known keys out of {@code generateKwargs} and never copied anything else into * {@code extraBody}. */ @ExtendWith(MockitoExtension.class) class OpenAiCompatibleChatModelBuilderTest { @Mock private ModelProviderService modelProviderService; private OpenAiCompatibleChatModelBuilder builder; private ModelProviderEntity provider; @BeforeEach void setUp() { // The ObjectProvider<...> constructor params (RestClient.Builder / WebClient.Builder / // ObservationRegistry) are only consumed by buildOpenAiApi(), never by // buildOpenAiOptions() under test here, so null is safe — nothing in this test class // exercises the HTTP-client-construction path. builder = new OpenAiCompatibleChatModelBuilder( modelProviderService, new ObjectMapper(), null, null, null); provider = new ModelProviderEntity(); provider.setProviderId("test-openai-compatible"); } @AfterEach void clearHolder() { ThinkingLevelHolder.clear(); } private static ModelConfigEntity model(String modelName) { ModelConfigEntity m = new ModelConfigEntity(); m.setModelName(modelName); return m; } @Test @DisplayName("Unrecognized top-level key (chat_template_kwargs) is forwarded into extraBody with the nested map intact") void unknownKey_chatTemplateKwargs_forwardedToExtraBody() { Map chatTemplateKwargs = Map.of("enable_thinking", false); Map kwargs = Map.of("chat_template_kwargs", chatTemplateKwargs); when(modelProviderService.readProviderGenerateKwargs(provider)).thenReturn(kwargs); OpenAiChatOptions options = builder.buildOpenAiOptions(model("gpt-4-turbo"), provider); assertNotNull(options.getExtraBody(), "extraBody must be populated when a passthrough key is present"); assertEquals(chatTemplateKwargs, options.getExtraBody().get("chat_template_kwargs"), "the nested map must be forwarded verbatim, not flattened or re-wrapped"); } @Test @DisplayName("Known key (temperature) is consumed via its typed option and NOT duplicated in extraBody; unknown key still forwarded") void knownKeyGoesTyped_unknownKeyGoesExtraBody_noDuplication() { Map kwargs = new LinkedHashMap<>(); kwargs.put("temperature", 0.7); kwargs.put("chat_template_kwargs", Map.of("enable_thinking", false)); when(modelProviderService.readProviderGenerateKwargs(provider)).thenReturn(kwargs); OpenAiChatOptions options = builder.buildOpenAiOptions(model("gpt-4-turbo"), provider); assertEquals(Double.valueOf(0.7), options.getTemperature(), "temperature must still be resolved into the typed OpenAiChatOptions field"); assertNotNull(options.getExtraBody()); assertFalse(options.getExtraBody().containsKey("temperature"), "temperature is a RESERVED_GENERATE_KWARGS_KEYS entry — it must not be duplicated into extraBody"); assertTrue(options.getExtraBody().containsKey("chat_template_kwargs"), "the unrecognized key must still be forwarded alongside the typed temperature handling"); } @Test @DisplayName("Known provider-discovery key (modelsPath) is reserved and never forwarded into extraBody") void knownKey_modelsPath_notForwardedToExtraBody() { Map kwargs = new LinkedHashMap<>(); kwargs.put("modelsPath", "/openai/v1/models"); kwargs.put("chat_template_kwargs", Map.of("enable_thinking", false)); when(modelProviderService.readProviderGenerateKwargs(provider)).thenReturn(kwargs); OpenAiChatOptions options = builder.buildOpenAiOptions(model("gpt-4-turbo"), provider); assertNotNull(options.getExtraBody()); assertFalse(options.getExtraBody().containsKey("modelsPath"), "modelsPath is consumed by OpenAiModelsPath and must not leak into chat completion request bodies"); assertTrue(options.getExtraBody().containsKey("chat_template_kwargs"), "unrecognized passthrough keys must still be forwarded"); } @Test @DisplayName("Empty generateKwargs: no exception, extraBody stays empty/null (pre-existing behavior preserved)") void emptyGenerateKwargs_noExceptionNoExtraBody() { when(modelProviderService.readProviderGenerateKwargs(provider)).thenReturn(Map.of()); OpenAiChatOptions options = assertDoesNotThrow( () -> builder.buildOpenAiOptions(model("gpt-4-turbo"), provider)); // collectPassthroughExtraBody returns Map.of() for empty kwargs, so the merge block in // buildOpenAiOptions is skipped entirely and extraBody is left at whatever // OpenAiChatOptions.builder().build() defaults to (null) — never a non-null empty map. assertTrue(options.getExtraBody() == null || options.getExtraBody().isEmpty(), "no passthrough keys present — extraBody must not be force-populated"); } @Test @DisplayName("Passthrough extraBody keys coexist with DeepSeekV4ThinkingDecorator-injected keys — neither clobbers the other") void passthroughAndDecoratorInjectedKeys_coexist() { // T2 sub-case 4: verify the merge-order comment in buildOpenAiOptions ("get-then-merge // rather than overwrite") actually holds up once a second layer (the DeepSeek V4 // decorator, applied at request time in build()) also writes into extraBody. Map chatTemplateKwargs = Map.of("enable_thinking", false); Map kwargs = Map.of("chat_template_kwargs", chatTemplateKwargs); when(modelProviderService.readProviderGenerateKwargs(provider)).thenReturn(kwargs); OpenAiChatOptions options = builder.buildOpenAiOptions(model("deepseek-v4-flash"), provider); assertEquals(chatTemplateKwargs, options.getExtraBody().get("chat_template_kwargs")); ThinkingLevelHolder.set("high"); DeepSeekV4ThinkingDecorator decorator = new DeepSeekV4ThinkingDecorator(new NoopChatModel()); Prompt patched = decorator.transform(new Prompt(List.of(new UserMessage("hi")), options)); OpenAiChatOptions patchedOptions = (OpenAiChatOptions) patched.getOptions(); assertEquals(chatTemplateKwargs, patchedOptions.getExtraBody().get("chat_template_kwargs"), "T1's passthrough entry must survive the decorator's own extraBody merge"); assertEquals(Map.of("type", "enabled"), patchedOptions.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD), "the decorator-injected thinking key must still be present alongside the passthrough entry"); } /* ---------- Test double ---------- */ private static class NoopChatModel implements ChatModel { @Override public ChatResponse call(Prompt prompt) { return null; } @Override public Flux stream(Prompt prompt) { return Flux.empty(); } } }