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 f2fc16a9..1cf6ebdc 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 @@ -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 passthroughExtraBody = ProviderGenerateKwargs.collectPassthroughExtraBody(kwargs); + if (!passthroughExtraBody.isEmpty()) { + Map existingExtraBody = options.getExtraBody(); + Map mergedExtraBody = (existingExtraBody == null) + ? new LinkedHashMap<>() + : new LinkedHashMap<>(existingExtraBody); + mergedExtraBody.putAll(passthroughExtraBody); + options.setExtraBody(mergedExtraBody); + } return options; } 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 25c3a6f7..df6a0f65 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 @@ -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; *

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}. + * + *

{@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 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 collectPassthroughExtraBody(Map kwargs) { + if (kwargs == null || kwargs.isEmpty()) { + return Map.of(); + } + Map 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. 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 ca6ebf2f..b4e031cc 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 @@ -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 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 kwargs = modelProviderService.readProviderGenerateKwargs(provider); String completionsPath = resolveCompletionsPath(baseUrl, kwargs); + Map 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 buildTestPromptRequestBody(String modelId, Map kwargs) { + Map 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}). 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 new file mode 100644 index 00000000..955bc6ef --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilderTest.java @@ -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}. + * + *

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(); } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryServiceTestPromptTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryServiceTestPromptTest.java new file mode 100644 index 00000000..05c742eb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryServiceTestPromptTest.java @@ -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}. + * + *

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 chatTemplateKwargs = Map.of("enable_thinking", false); + Map kwargs = Map.of("chat_template_kwargs", chatTemplateKwargs); + + Map 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 kwargs = new LinkedHashMap<>(); + kwargs.put("temperature", 0.9); + kwargs.put("maxTokens", 4096); + kwargs.put("chat_template_kwargs", Map.of("enable_thinking", false)); + + Map 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 kwargs = Map.of("customHeaders", Map.of("X-Foo", "bar")); + + Map 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 kwargs = new LinkedHashMap<>(); + kwargs.put("modelsPath", "/openai/v1/models"); + kwargs.put("chat_template_kwargs", Map.of("enable_thinking", false)); + + Map 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 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 requestBodyFromNull = ModelDiscoveryService.buildTestPromptRequestBody("gpt-4-turbo", null); + assertEquals(requestBody, requestBodyFromNull); + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 27183bf1..80c887c0 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, 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}', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 15e4412c..40772c0e 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 等生成参数。', + 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}',