fix: forward generateKwargs passthrough keys

Forward unrecognized OpenAI-compatible generateKwargs keys through extraBody while reserving documented provider control keys such as modelsPath.\n\nVerification:\n- cd mateclaw-server && mvn -pl . test -Dtest=OpenAiCompatibleChatModelBuilderTest,ModelDiscoveryServiceTestPromptTest\n- cd mateclaw-ui && node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit
This commit is contained in:
NhaNT 2026-08-15 12:32:03 +07:00 committed by GitHub
parent 41ffe38699
commit 265728bab7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 370 additions and 10 deletions

View File

@ -32,6 +32,7 @@ import vip.mate.llm.service.ModelProviderService;
import java.net.http.HttpClient;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
@ -183,6 +184,19 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
// Leaving it null keeps Spring AI from serializing the field; each node controls it
// when tools are present.
options.setStreamUsage(true);
// Forward unrecognized top-level generateKwargs keys as-is via extraBody (e.g. vLLM's
// chat_template_kwargs). Get-then-merge rather than overwrite, in case a future addition
// to buildOpenAiOptions ever sets extraBody above this point.
Map<String, Object> passthroughExtraBody = ProviderGenerateKwargs.collectPassthroughExtraBody(kwargs);
if (!passthroughExtraBody.isEmpty()) {
Map<String, Object> existingExtraBody = options.getExtraBody();
Map<String, Object> mergedExtraBody = (existingExtraBody == null)
? new LinkedHashMap<>()
: new LinkedHashMap<>(existingExtraBody);
mergedExtraBody.putAll(passthroughExtraBody);
options.setExtraBody(mergedExtraBody);
}
return options;
}

View File

@ -3,7 +3,9 @@ package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* Reads typed values out of a provider's {@code generateKwargs} map.
@ -11,13 +13,63 @@ import java.util.Map;
* <p>A lookup tries the camelCase key first, then a snake_case fallback, and also
* descends into a nested {@code chatOptions} map so an admin may specify an
* option under any of those shapes. Shared by the OpenAI-compatible chat model
* builder and the reasoning-effort resolver.
* builder, the reasoning-effort resolver, and the provider test-prompt path
* ({@code ModelDiscoveryService}) so every outbound request built from
* {@code generateKwargs} treats unrecognized keys the same way.
*/
@Slf4j
public final class ProviderGenerateKwargs {
private ProviderGenerateKwargs() {}
/**
* Top-level {@code generateKwargs} keys with dedicated typed handling elsewhere
* (both camelCase and snake_case spellings), plus the {@code chatOptions} nesting
* wrapper itself (its contents are already consumed via {@link #findOptionValue}).
* Centralized here so passthrough logic and known-key extraction across callers
* can't drift out of sync. Anything else at the top level of generateKwargs is
* forwarded verbatim see {@link #collectPassthroughExtraBody}.
*
* <p>{@code headers} / {@code customHeaders} are both reserved even though they're
* consumed by different callers ({@code OpenAiCompatibleChatModelBuilder} and
* {@code ModelDiscoveryService} respectively) both are injected as real HTTP
* headers, never as JSON body fields, so neither belongs in a passthrough body.
*/
public static final Set<String> RESERVED_GENERATE_KWARGS_KEYS = Set.of(
"temperature",
"maxTokens", "max_tokens",
"maxCompletionTokens", "max_completion_tokens",
"topP", "top_p",
"reasoningEffort", "reasoning_effort",
"enableSearch", "enable_search",
"searchStrategy", "search_strategy",
"headers",
"customHeaders", "custom_headers",
"completionsPath", "completions_path",
"modelsPath", "models_path",
"chatOptions", "chat_options"
);
/**
* Collect top-level {@code generateKwargs} entries not covered by
* {@link #RESERVED_GENERATE_KWARGS_KEYS} so they still reach the outbound
* request body (e.g. vLLM's {@code chat_template_kwargs} to disable Qwen
* thinking mode). Scoped to top-level keys only unrecognized keys nested
* inside {@code chatOptions} are an explicit non-goal and are not forwarded.
*/
public static Map<String, Object> collectPassthroughExtraBody(Map<String, Object> kwargs) {
if (kwargs == null || kwargs.isEmpty()) {
return Map.of();
}
Map<String, Object> passthrough = new LinkedHashMap<>();
kwargs.forEach((key, value) -> {
if (key != null && !RESERVED_GENERATE_KWARGS_KEYS.contains(key)) {
passthrough.put(key, value);
}
});
return passthrough;
}
/**
* Find a raw option value by key, trying the camelCase form then a
* snake_case fallback. Returns {@code null} when neither is present.

View File

@ -12,6 +12,7 @@ import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.OpenAiModelsPath;
import vip.mate.llm.chatmodel.ProviderGenerateKwargs;
import vip.mate.llm.model.*;
import vip.mate.llm.oauth.OpenAIOAuthService;
@ -656,16 +657,10 @@ public class ModelDiscoveryService {
throw new MateClawException("err.llm.base_url_missing", "Base URL 未配置");
}
Map<String, Object> requestBody = Map.of(
"model", modelId,
"messages", List.of(Map.of("role", "user", "content", "请回复:连接正常")),
"max_tokens", 10,
"temperature", 0
);
// generateKwargs 读取 completionsPath智谱等用 /chat/completions 而非 /v1/chat/completions
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
String completionsPath = resolveCompletionsPath(baseUrl, kwargs);
Map<String, Object> requestBody = buildTestPromptRequestBody(modelId, kwargs);
RestClient.RequestHeadersSpec<?> spec = openAiCompatibleClientBuilder()
.baseUrl(baseUrl)
@ -684,6 +679,26 @@ public class ModelDiscoveryService {
return extractOpenAiChatContent(body);
}
/**
* Build the smoke-test request body for the OpenAI-compatible test-prompt path.
* The core fields (model/messages/max_tokens/temperature) are fixed by design
* this is a minimal-token connectivity probe, not a real chat turn but any
* unrecognized top-level {@code generateKwargs} key (e.g. vLLM's
* {@code chat_template_kwargs} used to disable Qwen thinking mode) is forwarded
* verbatim, same as the runtime chat path in
* {@code OpenAiCompatibleChatModelBuilder#buildOpenAiOptions}. Passthrough is
* merged first so the fixed probe fields always win if a key ever collides.
* Package-private for unit tests.
*/
static Map<String, Object> buildTestPromptRequestBody(String modelId, Map<String, Object> kwargs) {
Map<String, Object> requestBody = new LinkedHashMap<>(ProviderGenerateKwargs.collectPassthroughExtraBody(kwargs));
requestBody.put("model", modelId);
requestBody.put("messages", List.of(Map.of("role", "user", "content", "请回复:连接正常")));
requestBody.put("max_tokens", 10);
requestBody.put("temperature", 0);
return requestBody;
}
/**
* Test a DashScope model using the **native** endpoint
* ({@code /api/v1/services/aigc/text-generation/generation}).

View File

@ -0,0 +1,176 @@
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}.
*
* <p>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<String, Object> chatTemplateKwargs = Map.of("enable_thinking", false);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> chatTemplateKwargs = Map.of("enable_thinking", false);
Map<String, Object> 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<ChatResponse> stream(Prompt prompt) { return Flux.empty(); }
}
}

View File

@ -0,0 +1,103 @@
package vip.mate.llm.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Regression coverage for {@link ModelDiscoveryService#buildTestPromptRequestBody}.
*
* <p>Prior to this fix, the Model Management "Test Model" / "Test Connection" button
* (backed by {@code sendOpenAiTestPrompt}) built its outbound request body from a
* hard-coded {@code Map.of(model, messages, max_tokens, temperature)} and never
* consulted {@code generateKwargs} at all (beyond {@code completionsPath} and
* {@code customHeaders}, applied separately). So an admin-configured passthrough
* key like vLLM's {@code chat_template_kwargs} (to disable Qwen thinking mode) was
* silently dropped on the test path even after the runtime chat path
* ({@code OpenAiCompatibleChatModelBuilder#buildOpenAiOptions}) started forwarding it
* "I configured disable-thinking but the UI test still shows thinking enabled".
*/
class ModelDiscoveryServiceTestPromptTest {
@Test
@DisplayName("Unrecognized top-level key (chat_template_kwargs) is forwarded into the test request body")
void unknownKey_chatTemplateKwargs_forwardedToRequestBody() {
Map<String, Object> chatTemplateKwargs = Map.of("enable_thinking", false);
Map<String, Object> kwargs = Map.of("chat_template_kwargs", chatTemplateKwargs);
Map<String, Object> requestBody = ModelDiscoveryService.buildTestPromptRequestBody("qwen3-32b", kwargs);
assertEquals(chatTemplateKwargs, requestBody.get("chat_template_kwargs"),
"the nested map must be forwarded verbatim, not flattened or re-wrapped");
assertEquals("qwen3-32b", requestBody.get("model"));
assertEquals(10, requestBody.get("max_tokens"));
assertEquals(0, requestBody.get("temperature"));
}
@Test
@DisplayName("Reserved key (temperature) in generateKwargs does not override the fixed smoke-test values")
void reservedKey_doesNotOverrideFixedProbeFields() {
Map<String, Object> kwargs = new LinkedHashMap<>();
kwargs.put("temperature", 0.9);
kwargs.put("maxTokens", 4096);
kwargs.put("chat_template_kwargs", Map.of("enable_thinking", false));
Map<String, Object> requestBody = ModelDiscoveryService.buildTestPromptRequestBody("qwen3-32b", kwargs);
assertEquals(0, requestBody.get("temperature"),
"the probe's fixed temperature=0 must win over a reserved generateKwargs key");
assertEquals(10, requestBody.get("max_tokens"),
"the probe's fixed max_tokens=10 must win over a reserved generateKwargs key");
assertFalse(requestBody.containsKey("maxTokens"),
"reserved keys (even in their original casing) must not leak into the body verbatim");
assertTrue(requestBody.containsKey("chat_template_kwargs"),
"the unrecognized key must still be forwarded alongside the fixed probe fields");
}
@Test
@DisplayName("customHeaders is reserved (consumed as real HTTP headers) and must not leak into the JSON body")
void customHeaders_doesNotLeakIntoRequestBody() {
Map<String, Object> kwargs = Map.of("customHeaders", Map.of("X-Foo", "bar"));
Map<String, Object> requestBody = ModelDiscoveryService.buildTestPromptRequestBody("qwen3-32b", kwargs);
assertFalse(requestBody.containsKey("customHeaders"),
"customHeaders is applied via applyCustomHeaders() as real HTTP headers, not as a body field");
}
@Test
@DisplayName("modelsPath is reserved (consumed by model discovery) and must not leak into the JSON body")
void modelsPath_doesNotLeakIntoRequestBody() {
Map<String, Object> kwargs = new LinkedHashMap<>();
kwargs.put("modelsPath", "/openai/v1/models");
kwargs.put("chat_template_kwargs", Map.of("enable_thinking", false));
Map<String, Object> requestBody = ModelDiscoveryService.buildTestPromptRequestBody("qwen3-32b", kwargs);
assertFalse(requestBody.containsKey("modelsPath"),
"modelsPath configures the list-models endpoint and is not a chat completion body field");
assertTrue(requestBody.containsKey("chat_template_kwargs"),
"unrecognized passthrough keys must still be forwarded");
}
@Test
@DisplayName("Empty or null generateKwargs: request body contains only the fixed probe fields")
void emptyOrNullGenerateKwargs_onlyFixedFields() {
Map<String, Object> requestBody = ModelDiscoveryService.buildTestPromptRequestBody("gpt-4-turbo", Map.of());
assertEquals(Set.of("model", "messages", "max_tokens", "temperature"), requestBody.keySet());
assertEquals("gpt-4-turbo", requestBody.get("model"));
assertEquals(10, requestBody.get("max_tokens"));
assertEquals(0, requestBody.get("temperature"));
Map<String, Object> requestBodyFromNull = ModelDiscoveryService.buildTestPromptRequestBody("gpt-4-turbo", null);
assertEquals(requestBody, requestBodyFromNull);
}
}

View File

@ -1099,7 +1099,7 @@ export default {
protocolAnthropic: 'Anthropic (Messages API)',
protocolGemini: 'Gemini Native',
protocolDashScope: 'DashScope Native',
advancedHint: 'Use this for generation options such as temperature, max_tokens, and top_p.',
advancedHint: 'Use this for generation options such as temperature, max_tokens, top_p, reasoning effort, enable_search/search_strategy, headers, and completions_path. Any other top-level key you add here is forwarded as-is into the outbound request body — for example, { "chat_template_kwargs": { "enable_thinking": false } } disables thinking mode on vLLM-served Qwen models. Keys nested inside a "chatOptions" object are not forwarded this way.',
requireApiKeyHint: 'Turn this off for internal or local OpenAI-compatible services that do not require auth. Connection tests will omit the Authorization header.',
fallbackPriorityHint: 'Pool try-order (lower wins): 0 = excluded, 1 = first in line, 2 = second, and so on. Providers sharing the same value are tried in alphabetical order of their ID.',
fallbackBadge: 'Preferred #{priority}',

View File

@ -944,7 +944,7 @@ export default {
protocolAnthropic: 'AnthropicMessages API',
protocolGemini: 'Gemini 原生',
protocolDashScope: 'DashScope 原生',
advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。',
advancedHint: '用于补充 temperature、max_tokens、top_p、reasoning effort、enable_search/search_strategy、headers、completions_path 等生成参数。除此之外的顶层 key 会原样透传到发往模型服务商的请求体中,例如 { "chat_template_kwargs": { "enable_thinking": false } } 可用于关闭 vLLM 部署的 Qwen 模型的思考模式。注意:嵌套在 "chatOptions" 对象内部的 key 不会被这样透传。',
requireApiKeyHint: '公司内部或本地 OpenAI 兼容服务如果不需要鉴权,可以关闭此项;测试连接时将不会发送 Authorization 头。',
fallbackPriorityHint: '池内尝试顺序数字越小越先0 = 不参与1 = 第一顺位2 = 第二顺位,依此类推。多个提供商共用同一数字时按 ID 字典序。',
fallbackBadge: '偏好 #{priority}',