From c326e98fd23a913478d9325e4daab6a77a3efce4 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 26 Oct 2026 06:54:13 -0400 Subject: [PATCH] fix(llm): apply thinking controls to deepseek-flash (#672) --- .../java/vip/mate/llm/model/ModelFamily.java | 9 +- ...eepSeekFlashFinalizationTransportTest.java | 110 ++++++++++++++++++ .../vip/mate/llm/model/ModelFamilyTest.java | 7 ++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekFlashFinalizationTransportTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java index 3511e4c0..e7bb617c 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java @@ -47,7 +47,7 @@ public enum ModelFamily { DEEPSEEK_REASONER(false, false, false, true, true, true), /** - * DeepSeek V4 reasoning 模型:deepseek-v4-flash / deepseek-v4-pro。 + * DeepSeek reasoning 模型:deepseek-flash / deepseek-v4-flash / deepseek-v4-pro。 *

* 与 {@link #DEEPSEEK_REASONER}(v3.2)的关键差异:V4 接受 {@code reasoning_effort} 字段, * 也不强制 temperature=1。OpenClaw 实现参考 {@code extensions/deepseek/models.ts:28-81} 标记 @@ -158,9 +158,12 @@ public enum ModelFamily { return KIMI_THINKING; } - // DeepSeek V4 reasoning 族:deepseek-v4-flash / deepseek-v4-pro + // deepseek-flash is the current API name; it uses the same thinking + // toggle as the legacy deepseek-v4-flash name. Missing it silently + // bypasses the decorator and leaves tool-free verification thinking on. // 优先匹配(在 DEEPSEEK_REASONER 之前),因为两者都含 "deepseek" 前缀但 V4 接受 reasoning_effort。 - if (normalized.equals("deepseek-v4-flash") || normalized.equals("deepseek-v4-pro")) { + if (normalized.equals("deepseek-flash") || normalized.equals("deepseek-v4-flash") + || normalized.equals("deepseek-v4-pro")) { return DEEPSEEK_V4_REASONING; } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekFlashFinalizationTransportTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekFlashFinalizationTransportTest.java new file mode 100644 index 00000000..44cf1083 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekFlashFinalizationTransportTest.java @@ -0,0 +1,110 @@ +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.Test; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +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.agent.GraphEventPublisher; +import vip.mate.agent.graph.NodeStreamingChatHelper; +import vip.mate.agent.graph.guard.TaskFinalizationCoordinator; +import vip.mate.agent.graph.guard.TaskFinalizationGate; +import vip.mate.agent.graph.state.ActionExecutionLedger; +import vip.mate.agent.graph.state.MateClawStateKeys; +import vip.mate.agent.runtime.state.RunState; +import vip.mate.channel.web.ChatStreamTracker; +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.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Reproduces #672 when the current Flash model name bypasses thinking controls. */ +class DeepSeekFlashFinalizationTransportTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void verifierDisablesFlashThinkingWithoutChangingRunThinkingOrReplayingFailures() throws Exception { + var requests = new CopyOnWriteArrayList(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/chat/completions", exchange -> { + JsonNode request = JSON.readTree(exchange.getRequestBody()); + requests.add(request); + boolean disabled = "disabled".equals(request.path("thinking").path("type").asText()); + // DeepSeek defaults to thinking enabled. Increasing max_tokens alone + // cannot ensure that a short verifier verdict will fit. + Map delta = disabled + ? Map.of("content", "{\"decision\":\"ACCEPT\",\"taskComplete\":true,\"remainingWork\":false," + + "\"failedCallCount\":5,\"recoveryClaims\":[],\"reason\":\"The answer accurately reports all five failures.\"}") + : Map.of("reasoning_content", "Reviewing failed operations"); + String event = JSON.writeValueAsString(Map.of("id", "flash-fixture", "object", "chat.completion.chunk", + "created", 1, "model", "deepseek-flash", "choices", List.of(Map.of("index", 0, + "delta", delta, "finish_reason", disabled ? "stop" : "length")))); + byte[] body = ("data: " + event + "\n\ndata: [DONE]\n\n").getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/event-stream; charset=utf-8"); + exchange.sendResponseHeaders(200, body.length); + try (var out = exchange.getResponseBody()) { out.write(body); } + }); + server.start(); + try { + var service = mock(ModelProviderService.class); + var provider = new ModelProviderEntity(); + provider.setProviderId("deepseek-fixture"); + provider.setRequireApiKey(false); + provider.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + when(service.isProviderConfigured(provider.getProviderId())).thenReturn(true); + when(service.readProviderGenerateKwargs(provider)).thenReturn(Map.of()); + var beans = new StaticListableBeanFactory(); + 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("deepseek-flash"); + config.setMaxTokens(8192); + var model = builder.build(config, provider, RetryTemplate.builder().maxAttempts(1).build()); + var tracker = mock(ChatStreamTracker.class); + var helper = new NodeStreamingChatHelper(tracker); + var coordinator = new TaskFinalizationCoordinator(helper, tracker); + var ledger = ActionExecutionLedger.fromEvents(java.util.stream.IntStream.range(0, 5) + .mapToObj(i -> GraphEventPublisher.toolComplete("failed-" + i, "execute_code", + "Execution failed: invalid code", false)).toList()); + var state = new RunState(); + state.replace(MateClawStateKeys.REQUESTED_THINKING_LEVEL, "high"); + ThinkingLevelHolder.set("high"); + var review = coordinator.review(model, "issue-672-flash", "Report the outcomes of five failed calls", + "All five calls failed; no successful execution or recovery was observed.", ledger, + "", 16000, 0, 8, 5, true, state); + assertEquals(TaskFinalizationGate.Outcome.ACCEPT, review.result().outcome()); + assertEquals(1, review.calls(), "Disabling thinking should avoid verifier budget retries"); + assertEquals(1, requests.size()); + var verifier = requests.get(0); + assertEquals("deepseek-flash", verifier.path("model").asText()); + assertEquals("disabled", verifier.path("thinking").path("type").asText()); + assertFalse(verifier.has("reasoning_effort")); + assertTrue(verifier.path("tools").isEmpty()); + assertEquals(1024, verifier.path("max_tokens").asInt()); + assertEquals("high", ThinkingLevelHolder.get()); + assertEquals("high", state.value(MateClawStateKeys.REQUESTED_THINKING_LEVEL, String.class).orElseThrow()); + + model.stream(new Prompt(List.of(new UserMessage("hello")))).collectList().block(); + assertEquals("enabled", requests.get(1).path("thinking").path("type").asText()); + } finally { + ThinkingLevelHolder.clear(); + server.stop(0); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java index 4b1fc07b..ae007fdf 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java @@ -17,6 +17,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ class ModelFamilyTest { + @Test + void currentDeepSeekFlashNameUsesThinkingProtocol() { + assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("deepseek-flash")); + assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect(" DeepSeek-Flash ")); + assertEquals(ModelFamily.STANDARD, ModelFamily.detect("deepseek-flash-custom")); + } + @Test @DisplayName("DeepSeek V4 (flash + pro) → DEEPSEEK_V4_REASONING with reasoning_effort enabled") void deepSeekV4_reasoning() {