fix: align generateKwargs reserved key readers

This commit is contained in:
matevip 2026-08-15 01:36:57 -04:00
parent 265728bab7
commit 0e534476a3
9 changed files with 57 additions and 14 deletions

View File

@ -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) {

View File

@ -92,7 +92,7 @@ public class DashScopeChatModelBuilder implements ChatModelBuilder {
return Boolean.TRUE.equals(runtimeModel.getEnableSearch());
}
Map<String, Object> 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()

View File

@ -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<String, Object> 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("/")) {

View File

@ -11,8 +11,8 @@ import java.util.Set;
* Reads typed values out of a provider's {@code generateKwargs} 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
* 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<String, Object>) optionsMap).get(key);
}

View File

@ -929,7 +929,7 @@ public class ModelDiscoveryService {
*/
private String resolveCompletionsPath(String baseUrl, Map<String, Object> 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<String, Object>) customHeaders).forEach((key, value) -> {
if (value != null) {

View File

@ -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<String, Object> 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() {

View File

@ -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<String, Object> 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"));
}
}

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, 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}',

View File

@ -944,7 +944,7 @@ export default {
protocolAnthropic: 'AnthropicMessages 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}',