mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
41ffe38699
commit
265728bab7
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}).
|
||||
|
||||
@ -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(); }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user