From 0e534476a3c768473ed01f651d922dd3f7827c9d Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 15 Aug 2026 01:36:57 -0400 Subject: [PATCH] fix: align generateKwargs reserved key readers --- .../vip/mate/agent/AgentGraphBuilder.java | 3 ++- .../chatmodel/DashScopeChatModelBuilder.java | 4 +-- .../OpenAiCompatibleChatModelBuilder.java | 8 +++--- .../llm/chatmodel/ProviderGenerateKwargs.java | 9 ++++--- .../llm/service/ModelDiscoveryService.java | 4 +-- .../OpenAiCompatibleChatModelBuilderTest.java | 14 +++++++++++ .../chatmodel/ProviderGenerateKwargsTest.java | 25 +++++++++++++++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 2 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 2 +- 9 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ProviderGenerateKwargsTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 0ae5680c..e50e7f3a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -38,6 +38,7 @@ import vip.mate.config.ReasoningRetentionProperties; import vip.mate.exception.MateClawException; import vip.mate.llm.chatmodel.HttpTimeouts; import vip.mate.llm.chatmodel.OpenAiCompatibleChatModelBuilder; +import vip.mate.llm.chatmodel.ProviderGenerateKwargs; import vip.mate.llm.chatmodel.ReasoningEffortResolver; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelFamily; @@ -403,7 +404,7 @@ public class AgentGraphBuilder { if (protocol == ModelProtocol.DASHSCOPE_NATIVE) { builtinSearchEnabled = dashScopeBuilder.isBuiltinSearchEnabled(runtimeModel, provider); } else if (OpenAiCompatibleChatModelBuilder.isKimiProvider(provider) - && Boolean.TRUE.equals(providerKwargs.get("enableSearch"))) { + && Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(providerKwargs, "enableSearch"))) { builtinSearchEnabled = true; } if (builtinSearchEnabled) { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DashScopeChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DashScopeChatModelBuilder.java index 37099067..d1939a50 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DashScopeChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DashScopeChatModelBuilder.java @@ -92,7 +92,7 @@ public class DashScopeChatModelBuilder implements ChatModelBuilder { return Boolean.TRUE.equals(runtimeModel.getEnableSearch()); } Map kwargs = modelProviderService.readProviderGenerateKwargs(provider); - Object kwargsSearch = kwargs.get("enableSearch"); + Object kwargsSearch = ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch"); if (kwargsSearch != null) { return Boolean.TRUE.equals(kwargsSearch); } @@ -145,7 +145,7 @@ public class DashScopeChatModelBuilder implements ChatModelBuilder { builder.withEnableSearch(true); String strategy = runtimeModel.getSearchStrategy(); if (!StringUtils.hasText(strategy)) { - strategy = (String) kwargs.get("searchStrategy"); + strategy = (String) ProviderGenerateKwargs.findOptionValue(kwargs, "searchStrategy"); } if (StringUtils.hasText(strategy)) { builder.withSearchOptions(DashScopeApiSpec.SearchOptions.builder() diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java index 1cf6ebdc..d798c8da 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java @@ -160,11 +160,11 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder { // built-in search: model-level field wins, provider generateKwargs as fallback boolean searchEnabled = Boolean.TRUE.equals(runtimeModel.getEnableSearch()) - || Boolean.TRUE.equals(kwargs.get("enableSearch")); + || Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch")); if (searchEnabled) { String strategy = runtimeModel.getSearchStrategy(); if (!StringUtils.hasText(strategy)) { - strategy = (String) kwargs.get("searchStrategy"); + strategy = (String) ProviderGenerateKwargs.findOptionValue(kwargs, "searchStrategy"); } OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize contextSize; try { @@ -277,7 +277,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder { } boolean kimiSearchEnabled = isKimiProvider(provider) - && Boolean.TRUE.equals(kwargs.get("enableSearch")); + && Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch")); ApiKey apiKeyImpl = (keyRequired && StringUtils.hasText(apiKey)) ? new SimpleApiKey(apiKey.trim()) @@ -410,7 +410,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder { private static final Pattern OPENAI_BASE_URL_VERSION_SUFFIX = Pattern.compile(".*/v\\d+$"); private String resolveOpenAiCompletionsPath(String baseUrl, Map kwargs) { - Object raw = kwargs.get("completionsPath"); + Object raw = ProviderGenerateKwargs.findOptionValue(kwargs, "completionsPath"); boolean explicit = raw instanceof String value && StringUtils.hasText(value); String path = explicit ? ((String) raw).trim() : "/v1/chat/completions"; if (!path.startsWith("/")) { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ProviderGenerateKwargs.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ProviderGenerateKwargs.java index df6a0f65..8687d3f3 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ProviderGenerateKwargs.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ProviderGenerateKwargs.java @@ -11,8 +11,8 @@ import java.util.Set; * Reads typed values out of a provider's {@code generateKwargs} map. * *

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 + * descends into a nested {@code chatOptions} / {@code chat_options} map — so an + * admin may specify an option under any of those shapes. Shared by the OpenAI-compatible chat model * 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. @@ -25,7 +25,7 @@ public final class 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}). + * wrappers themselves (their 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}. @@ -95,6 +95,9 @@ public final class ProviderGenerateKwargs { return kwargs.get(key); } Object chatOptions = kwargs.get("chatOptions"); + if (!(chatOptions instanceof Map)) { + chatOptions = kwargs.get("chat_options"); + } if (chatOptions instanceof Map optionsMap) { return ((Map) optionsMap).get(key); } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java index b4e031cc..e274869b 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java @@ -929,7 +929,7 @@ public class ModelDiscoveryService { */ private String resolveCompletionsPath(String baseUrl, Map kwargs) { if (kwargs != null) { - Object raw = kwargs.get("completionsPath"); + Object raw = ProviderGenerateKwargs.findOptionValue(kwargs, "completionsPath"); if (raw instanceof String value && StringUtils.hasText(value)) { String path = value.trim(); if (!path.startsWith("/")) { @@ -980,7 +980,7 @@ public class ModelDiscoveryService { if (kwargs == null) { return; } - Object customHeaders = kwargs.get("customHeaders"); + Object customHeaders = ProviderGenerateKwargs.findOptionValue(kwargs, "customHeaders"); if (customHeaders instanceof Map) { ((Map) customHeaders).forEach((key, value) -> { if (value != null) { diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilderTest.java index 955bc6ef..52981800 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilderTest.java @@ -127,6 +127,20 @@ class OpenAiCompatibleChatModelBuilderTest { "unrecognized passthrough keys must still be forwarded"); } + @Test + @DisplayName("Snake_case built-in search kwargs enable web search options") + void snakeCaseBuiltinSearchKwargs_enableWebSearchOptions() { + Map kwargs = new LinkedHashMap<>(); + kwargs.put("enable_search", true); + kwargs.put("search_strategy", "high"); + when(modelProviderService.readProviderGenerateKwargs(provider)).thenReturn(kwargs); + + OpenAiChatOptions options = builder.buildOpenAiOptions(model("gpt-4o-search-preview"), provider); + + assertNotNull(options.getWebSearchOptions(), + "enable_search should be treated the same as enableSearch"); + } + @Test @DisplayName("Empty generateKwargs: no exception, extraBody stays empty/null (pre-existing behavior preserved)") void emptyGenerateKwargs_noExceptionNoExtraBody() { diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ProviderGenerateKwargsTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ProviderGenerateKwargsTest.java new file mode 100644 index 00000000..078f9aa4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ProviderGenerateKwargsTest.java @@ -0,0 +1,25 @@ +package vip.mate.llm.chatmodel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ProviderGenerateKwargsTest { + + @Test + @DisplayName("findOptionValue reads snake_case keys nested under chat_options") + void findOptionValue_readsSnakeCaseNestedUnderChatOptionsSnakeCaseWrapper() { + Map kwargs = Map.of( + "chat_options", Map.of( + "enable_search", true, + "search_strategy", "pro" + ) + ); + + assertEquals(true, ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch")); + assertEquals("pro", ProviderGenerateKwargs.findOptionValue(kwargs, "searchStrategy")); + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 80c887c0..c8414b03 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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, 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.', + 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}', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 40772c0e..ff76c9ed 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -944,7 +944,7 @@ export default { protocolAnthropic: 'Anthropic(Messages API)', protocolGemini: 'Gemini 原生', protocolDashScope: 'DashScope 原生', - 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 不会被这样透传。', + 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}',