mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-03 06:25:32 +08:00
fix(llm): preserve vLLM reasoning and honor thinking switch (#633)
This commit is contained in:
parent
8337d13403
commit
3643052e9f
@ -1177,6 +1177,7 @@ public class NodeStreamingChatHelper {
|
|||||||
List<ToolCallAccumulator> toolCallAccumulators = new ArrayList<>();
|
List<ToolCallAccumulator> toolCallAccumulators = new ArrayList<>();
|
||||||
AtomicReference<AssistantMessage> lastAssistantMessage = new AtomicReference<>();
|
AtomicReference<AssistantMessage> lastAssistantMessage = new AtomicReference<>();
|
||||||
AtomicReference<Throwable> errorRef = new AtomicReference<>();
|
AtomicReference<Throwable> errorRef = new AtomicReference<>();
|
||||||
|
AtomicReference<String> finishReason = new AtomicReference<>();
|
||||||
AtomicInteger promptTokens = new AtomicInteger(0);
|
AtomicInteger promptTokens = new AtomicInteger(0);
|
||||||
AtomicInteger completionTokens = new AtomicInteger(0);
|
AtomicInteger completionTokens = new AtomicInteger(0);
|
||||||
// Prompt cache / reasoning counters; providers that don't report them stay 0.
|
// Prompt cache / reasoning counters; providers that don't report them stay 0.
|
||||||
@ -1292,6 +1293,11 @@ public class NodeStreamingChatHelper {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var generation = chatResponse.getResult();
|
var generation = chatResponse.getResult();
|
||||||
|
if (generation.getMetadata() != null
|
||||||
|
&& generation.getMetadata().getFinishReason() != null
|
||||||
|
&& !generation.getMetadata().getFinishReason().isBlank()) {
|
||||||
|
finishReason.set(generation.getMetadata().getFinishReason());
|
||||||
|
}
|
||||||
AssistantMessage msg = generation.getOutput();
|
AssistantMessage msg = generation.getOutput();
|
||||||
lastAssistantMessage.set(msg);
|
lastAssistantMessage.set(msg);
|
||||||
|
|
||||||
@ -1580,7 +1586,10 @@ public class NodeStreamingChatHelper {
|
|||||||
// ===== 成功(检查是否因 thinking-only 软上限或内容重复被截断) =====
|
// ===== 成功(检查是否因 thinking-only 软上限或内容重复被截断) =====
|
||||||
boolean truncatedByThinkingCap = thinkingOnlyCapTriggered.get();
|
boolean truncatedByThinkingCap = thinkingOnlyCapTriggered.get();
|
||||||
boolean truncatedByContentRepeat = contentRepeatCapTriggered.get();
|
boolean truncatedByContentRepeat = contentRepeatCapTriggered.get();
|
||||||
boolean truncated = truncatedByThinkingCap || truncatedByContentRepeat;
|
boolean thinkingTokenLimit = "length".equalsIgnoreCase(finishReason.get())
|
||||||
|
&& thinkingAccum.length() > 0 && contentAccum.toString().isBlank()
|
||||||
|
&& toolCallAccumulators.isEmpty();
|
||||||
|
boolean truncated = truncatedByThinkingCap || truncatedByContentRepeat || thinkingTokenLimit;
|
||||||
if (truncatedByThinkingCap) {
|
if (truncatedByThinkingCap) {
|
||||||
log.warn("[{}] LLM stream disposed: thinking-only soft cap reached for conversation {}",
|
log.warn("[{}] LLM stream disposed: thinking-only soft cap reached for conversation {}",
|
||||||
phase, conversationId);
|
phase, conversationId);
|
||||||
@ -1609,6 +1618,7 @@ public class NodeStreamingChatHelper {
|
|||||||
|
|
||||||
String truncationReason = truncatedByThinkingCap ? "thinking_only_no_content"
|
String truncationReason = truncatedByThinkingCap ? "thinking_only_no_content"
|
||||||
: truncatedByContentRepeat ? "content_repetition"
|
: truncatedByContentRepeat ? "content_repetition"
|
||||||
|
: thinkingTokenLimit ? "thinking_token_limit"
|
||||||
: null;
|
: null;
|
||||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||||
promptTokens.get(), completionTokens.get(),
|
promptTokens.get(), completionTokens.get(),
|
||||||
|
|||||||
@ -1160,21 +1160,20 @@ public class ReasoningNode implements NodeAction {
|
|||||||
// soft cap would be re-promoted to ERROR_FALLBACK and we'd lose the
|
// soft cap would be re-promoted to ERROR_FALLBACK and we'd lose the
|
||||||
// INCOMPLETE semantics.
|
// INCOMPLETE semantics.
|
||||||
|
|
||||||
if (result.partial() && "thinking_only_no_content".equals(result.errorMessage())) {
|
if (result.partial() && ("thinking_only_no_content".equals(result.errorMessage())
|
||||||
// Soft thinking-only loop: the helper disposed the upstream stream
|
|| "thinking_token_limit".equals(result.errorMessage()))) {
|
||||||
// because the model accumulated >= THINKING_ONLY_HARD_CAP_CHARS of
|
// Either our thinking-only cap or the provider's output-token budget
|
||||||
// reasoning_content without emitting any visible content or tool
|
// ended reasoning before any answer/tool call. Preserve the transcript
|
||||||
// calls. Treat as INCOMPLETE rather than fatal — the thinking text
|
// and surface INCOMPLETE rather than retrying a supposed empty response.
|
||||||
// has already been streamed and is preserved for the UI's collapse
|
|
||||||
// panel; the user gets a short fallback line they can retry from.
|
|
||||||
String partialThinking = result.thinking() != null ? result.thinking() : "";
|
String partialThinking = result.thinking() != null ? result.thinking() : "";
|
||||||
log.warn("[ReasoningNode] Thinking-only soft cap hit ({} thinking chars, no content/tools); " +
|
log.warn("[ReasoningNode] Thinking-only turn ended: {} ({} thinking chars); INCOMPLETE",
|
||||||
"INCOMPLETE",
|
result.errorMessage(), partialThinking.length());
|
||||||
partialThinking.length());
|
|
||||||
var builder = reasonOutput()
|
var builder = reasonOutput()
|
||||||
.needsToolCall(false)
|
.needsToolCall(false)
|
||||||
.shouldSummarize(false)
|
.shouldSummarize(false)
|
||||||
.finalAnswer("(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
|
.finalAnswer("thinking_token_limit".equals(result.errorMessage())
|
||||||
|
? "(模型在输出最终答案前已耗尽输出 token 预算。请关闭思考、适当增加模型最大输出 token 数,或拆分问题后重试。)"
|
||||||
|
: "(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
|
||||||
.llmCallCount(nextLlmCallCount)
|
.llmCallCount(nextLlmCallCount)
|
||||||
.finishReason(FinishReason.INCOMPLETE)
|
.finishReason(FinishReason.INCOMPLETE)
|
||||||
.contentStreamed(false)
|
.contentStreamed(false)
|
||||||
|
|||||||
@ -93,7 +93,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
|||||||
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
|
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
|
||||||
return new DeepSeekV4ThinkingDecorator(raw);
|
return new DeepSeekV4ThinkingDecorator(raw);
|
||||||
}
|
}
|
||||||
return raw;
|
return VllmThinkingDecorator.supports(provider, options) ? new VllmThinkingDecorator(raw) : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== chat options ====================
|
// ==================== chat options ====================
|
||||||
@ -252,6 +252,9 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
|||||||
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
|
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
|
||||||
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
|
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
|
||||||
|
|
||||||
|
restClientBuilder.requestInterceptor(OpenAiReasoningResponseNormalizer.blockingInterceptor());
|
||||||
|
webClientBuilder.filter(OpenAiReasoningResponseNormalizer.streamingFilter());
|
||||||
|
|
||||||
// Spring AI's OpenAiApi constructor sets User-Agent to "spring-ai" first, then addAll's
|
// Spring AI's OpenAiApi constructor sets User-Agent to "spring-ai" first, then addAll's
|
||||||
// our headers, so a custom User-Agent is appended rather than replaced. For providers
|
// our headers, so a custom User-Agent is appended rather than replaced. For providers
|
||||||
// that must masquerade as a specific client (e.g. kimi-code), force-override headers
|
// that must masquerade as a specific client (e.g. kimi-code), force-override headers
|
||||||
|
|||||||
@ -0,0 +1,97 @@
|
|||||||
|
package vip.mate.llm.chatmodel;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||||
|
import org.springframework.http.client.ClientHttpResponse;
|
||||||
|
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** Bridges vLLM's newer reasoning field to Spring AI 1.1.x's reasoning_content. */
|
||||||
|
final class OpenAiReasoningResponseNormalizer {
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
|
||||||
|
private OpenAiReasoningResponseNormalizer() {}
|
||||||
|
|
||||||
|
static String normalize(String body) {
|
||||||
|
if (!body.contains("\"reasoning\"")) return body;
|
||||||
|
try {
|
||||||
|
JsonNode root = JSON.readTree(body);
|
||||||
|
boolean changed = false;
|
||||||
|
for (JsonNode choice : root.path("choices")) {
|
||||||
|
changed |= normalizeMessage(choice.path("delta"));
|
||||||
|
changed |= normalizeMessage(choice.path("message"));
|
||||||
|
}
|
||||||
|
return changed ? JSON.writeValueAsString(root) : body;
|
||||||
|
} catch (JsonProcessingException ignored) {
|
||||||
|
// Keep malformed input intact: the SDK owns protocol error handling.
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean normalizeMessage(JsonNode node) {
|
||||||
|
if (node instanceof ObjectNode message && !message.hasNonNull("reasoning_content")
|
||||||
|
&& message.path("reasoning").isTextual()) {
|
||||||
|
message.set("reasoning_content", message.get("reasoning"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ExchangeFilterFunction streamingFilter() {
|
||||||
|
return (request, next) -> next.exchange(request).map(response -> {
|
||||||
|
if (!response.statusCode().is2xxSuccessful()
|
||||||
|
|| !response.headers().contentType().map(MediaType.TEXT_EVENT_STREAM::isCompatibleWith).orElse(false)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
// Decode complete SSE data events, not TCP/DataBuffer fragments. This
|
||||||
|
// preserves split UTF-8, multiline data, [DONE], backpressure and cancellation.
|
||||||
|
var events = response.bodyToFlux(String.class).map(OpenAiReasoningResponseNormalizer::normalize)
|
||||||
|
.map(data -> "data: " + data.replace("\n", "\ndata: ") + "\n\n")
|
||||||
|
.<DataBuffer>map(data -> DefaultDataBufferFactory.sharedInstance.wrap(data.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
// The function overload retains the source body. body(Flux) would
|
||||||
|
// release it immediately and subscribe to the HTTP body twice.
|
||||||
|
return response.mutate().headers(headers -> headers.remove(HttpHeaders.CONTENT_LENGTH)).body(original -> events).build();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static ClientHttpRequestInterceptor blockingInterceptor() {
|
||||||
|
return (request, body, execution) -> {
|
||||||
|
ClientHttpResponse response = execution.execute(request, body);
|
||||||
|
if (!response.getStatusCode().is2xxSuccessful()
|
||||||
|
|| response.getHeaders().getContentType() == null
|
||||||
|
|| !MediaType.APPLICATION_JSON.isCompatibleWith(response.getHeaders().getContentType())) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] bytes = normalize(new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8))
|
||||||
|
.getBytes(StandardCharsets.UTF_8);
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.putAll(response.getHeaders());
|
||||||
|
headers.setContentLength(bytes.length);
|
||||||
|
return new ClientHttpResponse() {
|
||||||
|
private final InputStream input = new ByteArrayInputStream(bytes);
|
||||||
|
@Override public HttpStatusCode getStatusCode() throws IOException { return response.getStatusCode(); }
|
||||||
|
@Override public String getStatusText() throws IOException { return response.getStatusText(); }
|
||||||
|
@Override public HttpHeaders getHeaders() { return headers; }
|
||||||
|
@Override public InputStream getBody() { return input; }
|
||||||
|
@Override public void close() { response.close(); }
|
||||||
|
};
|
||||||
|
} catch (IOException | RuntimeException error) {
|
||||||
|
response.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
package vip.mate.llm.chatmodel;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.model.ModelOptionsUtils;
|
||||||
|
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
||||||
|
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Per-request template switch for vLLM/Qwen, including arbitrary served model aliases. */
|
||||||
|
final class VllmThinkingDecorator implements ChatModel {
|
||||||
|
private final ChatModel delegate;
|
||||||
|
|
||||||
|
VllmThinkingDecorator(ChatModel delegate) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean supports(ModelProviderEntity provider, OpenAiChatOptions defaults) {
|
||||||
|
String id = provider.getProviderId();
|
||||||
|
if (id != null && id.toLowerCase(Locale.ROOT).contains("vllm")) return true;
|
||||||
|
// Custom providers can opt in by explicitly configuring the template switch.
|
||||||
|
return defaults.getExtraBody() != null
|
||||||
|
&& defaults.getExtraBody().get("chat_template_kwargs") instanceof Map<?, ?> template
|
||||||
|
&& template.containsKey("enable_thinking");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public ChatResponse call(Prompt prompt) { return delegate.call(transform(prompt)); }
|
||||||
|
@Override public Flux<ChatResponse> stream(Prompt prompt) { return delegate.stream(transform(prompt)); }
|
||||||
|
@Override public ChatOptions getDefaultOptions() { return delegate.getDefaultOptions(); }
|
||||||
|
|
||||||
|
private Prompt transform(Prompt prompt) {
|
||||||
|
// Capture on the caller's thread, before Reactor subscription or worker handoff.
|
||||||
|
String level = ThinkingLevelHolder.get();
|
||||||
|
if (level == null || level.isBlank()) return prompt;
|
||||||
|
ChatOptions runtime = prompt.getOptions();
|
||||||
|
OpenAiChatOptions patched = runtime instanceof OpenAiChatOptions options
|
||||||
|
? OpenAiChatOptions.fromOptions(options)
|
||||||
|
: runtime == null ? new OpenAiChatOptions()
|
||||||
|
: runtime instanceof ToolCallingChatOptions toolOptions
|
||||||
|
? ModelOptionsUtils.copyToTarget(toolOptions, ToolCallingChatOptions.class, OpenAiChatOptions.class)
|
||||||
|
: ModelOptionsUtils.copyToTarget(runtime, ChatOptions.class, OpenAiChatOptions.class);
|
||||||
|
Map<String, Object> extra = new LinkedHashMap<>();
|
||||||
|
Map<String, Object> template = new LinkedHashMap<>();
|
||||||
|
if (delegate.getDefaultOptions() instanceof OpenAiChatOptions defaults) {
|
||||||
|
mergeExtra(extra, template, defaults.getExtraBody());
|
||||||
|
}
|
||||||
|
mergeExtra(extra, template, patched.getExtraBody());
|
||||||
|
template.put("enable_thinking", !"off".equalsIgnoreCase(level));
|
||||||
|
extra.put("chat_template_kwargs", template);
|
||||||
|
patched.setExtraBody(extra);
|
||||||
|
// vLLM template switching does not use OpenAI's effort levels.
|
||||||
|
patched.setReasoningEffort(null);
|
||||||
|
return new Prompt(prompt.getInstructions(), patched);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void mergeExtra(Map<String, Object> extra, Map<String, Object> template, Map<String, Object> source) {
|
||||||
|
if (source == null) return;
|
||||||
|
extra.putAll(source);
|
||||||
|
if (source.get("chat_template_kwargs") instanceof Map<?, ?> values) {
|
||||||
|
values.forEach((key, value) -> { if (key instanceof String name) template.put(name, value); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -112,4 +112,36 @@ class NodeStreamingChatHelperThinkingCapTest {
|
|||||||
assertEquals(hugeThinking, result.thinking(),
|
assertEquals(hugeThinking, result.thinking(),
|
||||||
"Thinking transcript is preserved so the UI can show it in a collapse panel");
|
"Thinking transcript is preserved so the UI can show it in a collapse panel");
|
||||||
}
|
}
|
||||||
|
@Test
|
||||||
|
void reasoningOnlyTokenLimitIsIncompleteRatherThanEmptyOrSuccessful() {
|
||||||
|
var message = AssistantMessage.builder().content("")
|
||||||
|
.properties(Map.of("reasoningContent", "still reasoning")).build();
|
||||||
|
var generation = new Generation(message, ChatGenerationMetadata.builder().finishReason("length").build());
|
||||||
|
var model = mock(ChatModel.class);
|
||||||
|
when(model.stream(any(Prompt.class))).thenReturn(Flux.just(new ChatResponse(List.of(generation))));
|
||||||
|
var result = new NodeStreamingChatHelper(streamTracker)
|
||||||
|
.streamCall(model, smallPrompt(), "conv-length", "reasoning");
|
||||||
|
assertTrue(result.partial());
|
||||||
|
assertEquals("thinking_token_limit", result.errorMessage());
|
||||||
|
assertEquals("still reasoning", result.thinking());
|
||||||
|
assertEquals("", result.text());
|
||||||
|
assertEquals(NodeStreamingChatHelper.ErrorType.NONE, result.errorType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenLimitWithContentOrToolsIsNotMisclassifiedAsThinkingOnly() {
|
||||||
|
for (var message : List.of(
|
||||||
|
AssistantMessage.builder().content("partial answer")
|
||||||
|
.properties(Map.of("reasoningContent", "reasoning")).build(),
|
||||||
|
AssistantMessage.builder().content("").properties(Map.of("reasoningContent", "reasoning"))
|
||||||
|
.toolCalls(List.of(new AssistantMessage.ToolCall("id", "function", "search", "{}"))).build())) {
|
||||||
|
var generation = new Generation(message, ChatGenerationMetadata.builder().finishReason("length").build());
|
||||||
|
var model = mock(ChatModel.class);
|
||||||
|
when(model.stream(any(Prompt.class))).thenReturn(Flux.just(new ChatResponse(List.of(generation))));
|
||||||
|
var result = new NodeStreamingChatHelper(streamTracker)
|
||||||
|
.streamCall(model, smallPrompt(), "conv-length-progress", "reasoning");
|
||||||
|
assertNotEquals("thinking_token_limit", result.errorMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -428,6 +428,23 @@ class ReasoningNodeOutputTest {
|
|||||||
"Thinking transcript must be preserved for the UI's collapse panel");
|
"Thinking transcript must be preserved for the UI's collapse panel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thinkingTokenLimit_explainsBudgetAndDoesNotRetryOrExposeReasoningAsAnswer() throws Exception {
|
||||||
|
var result = new NodeStreamingChatHelper.StreamResult(
|
||||||
|
"", "unfinished reasoning", new AssistantMessage(""), List.of(), false,
|
||||||
|
100, 256, true, "thinking_token_limit", NodeStreamingChatHelper.ErrorType.NONE);
|
||||||
|
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result);
|
||||||
|
Map<String, Object> output = createNode().apply(buildStaleState());
|
||||||
|
assertControlFlagsCleared(output, "thinkingTokenLimit");
|
||||||
|
assertEquals("incomplete", output.get(FINISH_REASON));
|
||||||
|
assertEquals("unfinished reasoning", output.get(FINAL_THINKING));
|
||||||
|
String answer = (String) output.get(FINAL_ANSWER);
|
||||||
|
assertTrue(answer.contains("token 预算"));
|
||||||
|
assertTrue(answer.contains("关闭思考"));
|
||||||
|
assertFalse(answer.contains("unfinished reasoning"));
|
||||||
|
verify(streamingHelper, times(1)).streamCall(any(), any(), anyString(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
// ===== CancellationException (no content stop) =====
|
// ===== CancellationException (no content stop) =====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
package vip.mate.llm.chatmodel;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
|
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.reactive.function.client.ClientRequest;
|
||||||
|
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class OpenAiReasoningResponseNormalizerTest {
|
||||||
|
@Test
|
||||||
|
void eventMappingSubscribesOnceAndPropagatesCancellation() {
|
||||||
|
AtomicInteger subscriptions = new AtomicInteger();
|
||||||
|
AtomicInteger cancellations = new AtomicInteger();
|
||||||
|
Flux<DataBuffer> body = Flux.<DataBuffer>defer(() -> {
|
||||||
|
subscriptions.incrementAndGet();
|
||||||
|
String event = "data: {\"choices\":[{\"delta\":{\"reasoning\":\"thinking\"}}]}\n\n";
|
||||||
|
return Flux.concat(Flux.just(DefaultDataBufferFactory.sharedInstance.wrap(event.getBytes(StandardCharsets.UTF_8))),
|
||||||
|
Flux.never());
|
||||||
|
}).doOnCancel(cancellations::incrementAndGet);
|
||||||
|
ClientResponse source = ClientResponse.create(HttpStatus.OK)
|
||||||
|
.header("Content-Type", MediaType.TEXT_EVENT_STREAM_VALUE).body(body).build();
|
||||||
|
var request = ClientRequest.create(HttpMethod.POST, URI.create("http://localhost/v1/chat/completions")).build();
|
||||||
|
var filtered = OpenAiReasoningResponseNormalizer.streamingFilter()
|
||||||
|
.filter(request, ignored -> Mono.just(source)).block();
|
||||||
|
assertEquals(0, subscriptions.get(), "filter must not eagerly drain the HTTP body");
|
||||||
|
var events = filtered.bodyToFlux(String.class).take(1).collectList().block(Duration.ofSeconds(2));
|
||||||
|
assertEquals(1, events.size());
|
||||||
|
assertTrue(events.getFirst().contains("reasoning_content"));
|
||||||
|
assertEquals(1, subscriptions.get());
|
||||||
|
assertEquals(1, cancellations.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedFramesAndDoneRemainUnchanged() {
|
||||||
|
assertEquals("[DONE]", OpenAiReasoningResponseNormalizer.normalize("[DONE]"));
|
||||||
|
String malformed = "{\"reasoning\": broken";
|
||||||
|
assertEquals(malformed, OpenAiReasoningResponseNormalizer.normalize(malformed));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,233 @@
|
|||||||
|
package vip.mate.llm.chatmodel;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import io.micrometer.observation.ObservationRegistry;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||||
|
import org.springframework.beans.factory.support.StaticListableBeanFactory;
|
||||||
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** Exercises the real Spring AI HTTP/SSE boundary, including fragmented UTF-8 events. */
|
||||||
|
class VllmThinkingCompatibilityTest {
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
private final LinkedBlockingQueue<JsonNode> requests = new LinkedBlockingQueue<>();
|
||||||
|
private HttpServer server;
|
||||||
|
private org.springframework.ai.openai.api.OpenAiApi api;
|
||||||
|
private volatile String response;
|
||||||
|
private volatile boolean streaming = true;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void start() throws Exception {
|
||||||
|
response = "data: " + chunk("\"content\":\"answer\"", "stop") + "\n\ndata: [DONE]\n\n";
|
||||||
|
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
server.createContext("/v1/chat/completions", exchange -> {
|
||||||
|
requests.add(json.readTree(exchange.getRequestBody()));
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", streaming ? "text/event-stream" : "application/json");
|
||||||
|
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.sendResponseHeaders(200, 0);
|
||||||
|
try (var out = exchange.getResponseBody()) {
|
||||||
|
for (int offset = 0; offset < bytes.length; offset += 7) {
|
||||||
|
out.write(bytes, offset, Math.min(7, bytes.length - offset));
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
ThinkingLevelHolder.clear();
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatModel model(String providerId, Map<String, Object> kwargs) {
|
||||||
|
return model(providerId, kwargs, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatModel model(String providerId, Map<String, Object> kwargs, boolean apiOnly) {
|
||||||
|
var service = mock(ModelProviderService.class);
|
||||||
|
when(service.isProviderConfigured(providerId)).thenReturn(true);
|
||||||
|
var provider = new ModelProviderEntity();
|
||||||
|
provider.setProviderId(providerId);
|
||||||
|
provider.setRequireApiKey(false);
|
||||||
|
provider.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort());
|
||||||
|
when(service.readProviderGenerateKwargs(provider)).thenReturn(kwargs);
|
||||||
|
var beans = new StaticListableBeanFactory();
|
||||||
|
beans.addBean("rest", RestClient.builder());
|
||||||
|
// The application enables this restricted header at process startup; this
|
||||||
|
// fixture only tests payload compatibility and does not run that bootstrap.
|
||||||
|
beans.addBean("web", WebClient.builder().filter((request, next) -> next.exchange(
|
||||||
|
org.springframework.web.reactive.function.client.ClientRequest.from(request)
|
||||||
|
.headers(headers -> headers.remove("Connection")).build())));
|
||||||
|
beans.addBean("observations", ObservationRegistry.NOOP);
|
||||||
|
var builder = new OpenAiCompatibleChatModelBuilder(service,
|
||||||
|
beans.getBeanProvider(RestClient.Builder.class), beans.getBeanProvider(WebClient.Builder.class),
|
||||||
|
beans.getBeanProvider(ObservationRegistry.class));
|
||||||
|
var config = new ModelConfigEntity();
|
||||||
|
config.setModelName("sdhs-model"); // A custom alias must not hide vLLM capabilities.
|
||||||
|
config.setMaxTokens(256);
|
||||||
|
if (apiOnly) {
|
||||||
|
api = builder.buildOpenAiApi(provider, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return builder.build(config, provider, RetryTemplate.builder().maxAttempts(1).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String chunk(String delta, String finish) {
|
||||||
|
return "{\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"sdhs-model\","
|
||||||
|
+ "\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"," + delta
|
||||||
|
+ "},\"finish_reason\":" + (finish == null ? "null" : "\"" + finish + "\"") + "}]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAcceptsBothReasoningFieldsWithoutChangingContent() {
|
||||||
|
response = ": heartbeat\r\n\r\ndata: " + chunk("\"reasoning\":\"推理一\"", null)
|
||||||
|
+ "\r\n\r\ndata: " + chunk("\"reasoning_content\":\"推理二\"", null)
|
||||||
|
+ "\n\ndata: " + chunk("\"content\":\"literal reasoning and reasoning_content\"", "stop")
|
||||||
|
+ "\n\ndata: [DONE]\n\n";
|
||||||
|
var responses = model("vllm", Map.of()).stream(new Prompt("hi")).collectList().block(Duration.ofSeconds(10));
|
||||||
|
String reasoning = responses.stream().map(r -> r.getResult().getOutput().getMetadata().get("reasoningContent"))
|
||||||
|
.filter(java.util.Objects::nonNull).map(Object::toString).reduce("", String::concat);
|
||||||
|
assertEquals("推理一推理二", reasoning);
|
||||||
|
assertEquals("literal reasoning and reasoning_content", responses.stream()
|
||||||
|
.map(r -> r.getResult().getOutput().getText()).filter(java.util.Objects::nonNull).reduce("", String::concat));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blockingCallAcceptsReasoningAlias() {
|
||||||
|
streaming = false;
|
||||||
|
response = "{\"id\":\"test\",\"created\":1,\"model\":\"sdhs-model\",\"choices\":[{\"index\":0,"
|
||||||
|
+ "\"message\":{\"role\":\"assistant\",\"content\":\"answer\",\"reasoning\":\"分析\"},\"finish_reason\":\"stop\"}]}";
|
||||||
|
model("vllm", Map.of(), true);
|
||||||
|
var result = api.chatCompletionEntity(new org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest(List.of(), false));
|
||||||
|
assertEquals("分析", result.getBody().choices().getFirst().message().reasoningContent());
|
||||||
|
assertEquals("answer", result.getBody().choices().getFirst().message().content());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void switchIsPerRequestAndPreservesTemplateDefaultsAndRuntimeOptions() throws Exception {
|
||||||
|
var model = model("vllm", Map.of("chat_template_kwargs", Map.of("enable_thinking", true, "custom", "keep")));
|
||||||
|
var options = OpenAiChatOptions.builder().temperature(0.3).extraBody(Map.of("top_k", 20)).build();
|
||||||
|
ThinkingLevelHolder.set("off");
|
||||||
|
var stream = model.stream(new Prompt("hi", options));
|
||||||
|
ThinkingLevelHolder.clear(); // Capture before a subscription moves to a Reactor worker.
|
||||||
|
stream.collectList().block(Duration.ofSeconds(10));
|
||||||
|
var off = requests.poll(1, TimeUnit.SECONDS);
|
||||||
|
assertEquals(false, off.at("/chat_template_kwargs/enable_thinking").booleanValue());
|
||||||
|
assertEquals("keep", off.at("/chat_template_kwargs/custom").asText());
|
||||||
|
assertEquals(20, off.path("top_k").asInt());
|
||||||
|
assertEquals(0.3, off.path("temperature").asDouble());
|
||||||
|
assertFalse(off.has("reasoning_effort"));
|
||||||
|
ThinkingLevelHolder.set("high");
|
||||||
|
model.stream(new Prompt("hi")).collectList().block(Duration.ofSeconds(10));
|
||||||
|
var on = requests.poll(1, TimeUnit.SECONDS);
|
||||||
|
assertTrue(on.at("/chat_template_kwargs/enable_thinking").booleanValue());
|
||||||
|
assertEquals(Map.of("top_k", 20), options.getExtraBody());
|
||||||
|
assertEquals(true, ((Map<?, ?>) ((OpenAiChatOptions) model.getDefaultOptions()).getExtraBody()
|
||||||
|
.get("chat_template_kwargs")).get("enable_thinking"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unspecifiedLevelPreservesExplicitProviderDefault() throws Exception {
|
||||||
|
model("vllm", Map.of("chat_template_kwargs", Map.of("enable_thinking", false)))
|
||||||
|
.stream(new Prompt("hi")).collectList().block(Duration.ofSeconds(10));
|
||||||
|
assertFalse(requests.poll(1, TimeUnit.SECONDS).at("/chat_template_kwargs/enable_thinking").booleanValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void otherProvidersDoNotReceiveVllmOptions() throws Exception {
|
||||||
|
ThinkingLevelHolder.set("off");
|
||||||
|
model("azure", Map.of()).stream(new Prompt("hi")).collectList().block(Duration.ofSeconds(10));
|
||||||
|
assertFalse(requests.poll(1, TimeUnit.SECONDS).has("chat_template_kwargs"));
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void explicitTemplateSwitchOptsCustomProviderIn() throws Exception {
|
||||||
|
ThinkingLevelHolder.set("off");
|
||||||
|
model("local-inference", Map.of("chat_template_kwargs", Map.of("enable_thinking", true)))
|
||||||
|
.stream(new Prompt("hi")).collectList().block(Duration.ofSeconds(10));
|
||||||
|
var sent = requests.poll(1, TimeUnit.SECONDS);
|
||||||
|
assertTrue(sent.at("/chat_template_kwargs/enable_thinking").isBoolean());
|
||||||
|
assertFalse(sent.at("/chat_template_kwargs/enable_thinking").booleanValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void vllmWithoutTemplateDefaultsReceivesExplicitOff() throws Exception {
|
||||||
|
ThinkingLevelHolder.set("off");
|
||||||
|
model("vllm", Map.of()).stream(new Prompt("hi")).collectList().block(Duration.ofSeconds(10));
|
||||||
|
var sent = requests.poll(1, TimeUnit.SECONDS);
|
||||||
|
assertTrue(sent.at("/chat_template_kwargs/enable_thinking").isBoolean());
|
||||||
|
assertFalse(sent.at("/chat_template_kwargs/enable_thinking").booleanValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canonicalReasoningWinsAndStructuredToolArgumentsAreUntouched() throws Exception {
|
||||||
|
// Build arguments as JSON rather than relying on nested Java/JSON escaping.
|
||||||
|
var root = json.createObjectNode();
|
||||||
|
var delta = root.putArray("choices").addObject().putObject("delta");
|
||||||
|
delta.put("reasoning", "alias");
|
||||||
|
delta.put("reasoning_content", "canonical");
|
||||||
|
delta.putArray("tool_calls").addObject().putObject("function")
|
||||||
|
.put("arguments", "{reasoning: keep}");
|
||||||
|
assertEquals(root.toString(), OpenAiReasoningResponseNormalizer.normalize(root.toString()));
|
||||||
|
delta.remove("reasoning_content");
|
||||||
|
JsonNode normalized = json.readTree(OpenAiReasoningResponseNormalizer.normalize(root.toString()));
|
||||||
|
assertEquals("alias", normalized.at("/choices/0/delta/reasoning_content").asText());
|
||||||
|
assertEquals("{reasoning: keep}", normalized.at("/choices/0/delta/tool_calls/0/function/arguments").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void genericToolCallingOptionsSurviveThinkingSwitch() throws Exception {
|
||||||
|
var callback = mock(org.springframework.ai.tool.ToolCallback.class);
|
||||||
|
when(callback.getToolDefinition()).thenReturn(org.springframework.ai.tool.definition.ToolDefinition.builder()
|
||||||
|
.name("lookup").description("lookup a value").inputSchema("{} ").build());
|
||||||
|
var options = org.springframework.ai.model.tool.ToolCallingChatOptions.builder()
|
||||||
|
.toolCallbacks(List.of(callback)).internalToolExecutionEnabled(false).build();
|
||||||
|
ThinkingLevelHolder.set("off");
|
||||||
|
model("vllm", Map.of()).stream(new Prompt("hi", options)).collectList().block(Duration.ofSeconds(10));
|
||||||
|
var sent = requests.poll(1, TimeUnit.SECONDS);
|
||||||
|
assertEquals("lookup", sent.at("/tools/0/function/name").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamedToolCallFragmentsSurviveReasoningNormalization() throws Exception {
|
||||||
|
var first = json.readTree(chunk("\"reasoning\":\"planning\"", null));
|
||||||
|
var firstDelta = (com.fasterxml.jackson.databind.node.ObjectNode) first.at("/choices/0/delta");
|
||||||
|
var firstTool = firstDelta.putArray("tool_calls").addObject();
|
||||||
|
firstTool.put("index", 0).put("id", "call-1").put("type", "function");
|
||||||
|
firstTool.putObject("function").put("name", "lookup").put("arguments", "{\"reasoning\":");
|
||||||
|
var second = json.readTree(chunk("\"content\":null", "tool_calls"));
|
||||||
|
var secondDelta = (com.fasterxml.jackson.databind.node.ObjectNode) second.at("/choices/0/delta");
|
||||||
|
secondDelta.putArray("tool_calls").addObject().put("index", 0)
|
||||||
|
.putObject("function").put("arguments", "\"keep\"}");
|
||||||
|
response = "data: " + first + "\n\ndata: " + second + "\n\ndata: [DONE]\n\n";
|
||||||
|
var result = model("vllm", Map.of()).stream(new Prompt("hi")).collectList().block(Duration.ofSeconds(10));
|
||||||
|
var calls = result.stream().flatMap(r -> r.getResult().getOutput().getToolCalls().stream()).toList();
|
||||||
|
assertEquals(1, calls.size());
|
||||||
|
assertEquals("lookup", calls.getFirst().name());
|
||||||
|
assertEquals("keep", json.readTree(calls.getFirst().arguments()).path("reasoning").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user