refactor(llm): decouple model construction from the agent graph layer (#147)

This commit is contained in:
matevip 2026-05-18 07:47:49 +08:00
parent 7b053052d7
commit a88edbdd07
43 changed files with 1662 additions and 1613 deletions

View File

@ -24,7 +24,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
// not the chat model. We don't use it model configuration is admin-UI driven and
// built by AgentDashScopeChatModelBuilder. Its auto-config strictly requires
// built by DashScopeChatModelBuilder. Its auto-config strictly requires
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
// ApplicationContext fail when users deploy via Docker without setting the key.
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,

View File

@ -15,6 +15,7 @@ import vip.mate.agent.event.AgentLifecycleEvent;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;

View File

@ -1,33 +0,0 @@
package vip.mate.agent;
/**
* 请求级思考深度的 ThreadLocal 持有器
* <p>
* 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode
* 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口
* <p>
* 支持的值off / low / medium / high / maxnull 表示跟随模型默认
*
* @author MateClaw Team
*/
public final class ThinkingLevelHolder {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
private ThinkingLevelHolder() {}
public static void set(String level) {
HOLDER.set(level);
}
/**
* 获取当前请求的思考级别null 表示未设置跟随模型默认
*/
public static String get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}

View File

@ -15,6 +15,7 @@ import vip.mate.agent.binding.repository.AgentToolBindingMapper;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.routing.AgentBindingResolver;
import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.model.SkillEntity;
@ -41,7 +42,7 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Service
public class AgentBindingService {
public class AgentBindingService implements AgentBindingResolver {
private final AgentSkillBindingMapper skillBindingMapper;
private final AgentToolBindingMapper toolBindingMapper;
@ -112,6 +113,7 @@ public class AgentBindingService {
* 获取 Agent 绑定的 enabled skill ID 集合
* 返回 null 表示该 agent 没有自定义绑定使用全局默认
*/
@Override
public Set<Long> getBoundSkillIds(Long agentId) {
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
if (bindings.isEmpty()) {
@ -668,6 +670,7 @@ public class AgentBindingService {
* <p>Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the
* fallback chain order per agent.</p>
*/
@Override
public List<String> getPreferredProviderIds(Long agentId) {
if (agentId == null) return Collections.emptyList();
return listProviderPreferences(agentId).stream()

View File

@ -1,66 +0,0 @@
package vip.mate.agent.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Lazy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* Thin strategy adapter for {@link ModelProtocol#OPENAI_COMPATIBLE}.
* Delegates to {@link AgentGraphBuilder}'s helpers; see
* {@link AgentDashScopeChatModelBuilder} for the rationale of the delegate
* pattern and the {@code @Lazy} cycle break.
*/
@Component
public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
private final AgentGraphBuilder agentGraphBuilder;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
public AgentOpenAiCompatibleChatModelBuilder(
@Lazy AgentGraphBuilder agentGraphBuilder,
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
this.agentGraphBuilder = agentGraphBuilder;
this.observationRegistryProvider = observationRegistryProvider;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.OPENAI_COMPATIBLE;
}
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
// RFC-03 Lane B1 pass model.requestTimeoutSeconds so providers /
// models with extended-thinking p99s don't false-positive on the
// hardcoded 180s read timeout.
OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider, model.getRequestTimeoutSeconds());
OpenAiChatOptions options = agentGraphBuilder.buildOpenAiOptions(model, provider);
ChatModel raw = OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
// DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
// strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
// those directly wrap with a per-request payload patcher. See
// DeepSeekV4ThinkingDecorator javadoc.
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
return new DeepSeekV4ThinkingDecorator(raw);
}
return raw;
}
}

View File

@ -11,7 +11,7 @@ package vip.mate.agent.context;
* call (set on entry, cleared in {@code finally}). Once written into the
* graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN},
* the rest of the runtime reads via the typed accessor no further ThreadLocal
* access. Mirrors {@link vip.mate.agent.ThinkingLevelHolder}.
* access. Mirrors {@link vip.mate.llm.chatmodel.ThinkingLevelHolder}.
*/
public final class ChatOriginHolder {

View File

@ -9,8 +9,8 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import vip.mate.agent.AssistantThinkingRelay;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
import reactor.core.Disposable;
@ -873,7 +873,7 @@ public class NodeStreamingChatHelper {
thinkingAccum.append(thinkingDelta);
// thinkingLevel=off 时不广播 thinking模型仍可能产生但前端不展示
boolean suppressThinking = "off".equalsIgnoreCase(
vip.mate.agent.ThinkingLevelHolder.get());
vip.mate.llm.chatmodel.ThinkingLevelHolder.get());
if (broadcast && !suppressThinking) {
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
}

View File

@ -501,7 +501,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 迭代控制深度思考模式允许更多迭代思考需要更多轮工具调用
// maxIterations<=0 表示软上限解除 LLM 自己决定何时收尾加分要短路
// 否则 thinking-on 会把"无限"误算成 5变成"5 步就停"
String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get();
String thinkingLevel = vip.mate.llm.chatmodel.ThinkingLevelHolder.get();
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
int effectiveMaxIterations = (maxIterations <= 0)
? 0

View File

@ -18,7 +18,7 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.StringUtils;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.ThinkingLevelHolder;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.RuntimeContextInjector;

View File

@ -13,7 +13,7 @@ import java.util.Optional;
* <p>Combines {@link ClaudeCodeCredentialsReader},
* {@link ClaudeCodeTokenRefresher}, and {@link ClaudeCodeCredentialsWriter}
* to expose a single {@link #getValidToken()} entry point that
* {@code AgentClaudeCodeChatModelBuilder} (PR-2) will call on every request.
* {@code ClaudeCodeChatModelBuilder} (PR-2) will call on every request.
*
* <h2>Behavior</h2>
* <ol>

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import lombok.extern.slf4j.Slf4j;
@ -13,10 +13,8 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import vip.mate.agent.ThinkingLevelHolder;
import vip.mate.exception.MateClawException;
import vip.mate.llm.cache.AnthropicCacheOptionsFactory;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
@ -30,12 +28,11 @@ import java.time.Duration;
*
* <p>Owns the full Anthropic construction logic API client + chat options
* including the extended-thinking budget mapping (low/medium/high/max
* 4k/8k/16k/32k thinking tokens) and prompt-cache options. PR-0b moved this
* out of {@code AgentGraphBuilder}.</p>
* 4k/8k/16k/32k thinking tokens) and prompt-cache options.</p>
*/
@Slf4j
@Component
public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
public class AnthropicChatModelBuilder implements ChatModelBuilder {
private final ModelProviderService modelProviderService;
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
@ -43,7 +40,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
private final AnthropicCacheOptionsFactory anthropicCacheOptionsFactory;
public AgentAnthropicChatModelBuilder(
public AnthropicChatModelBuilder(
ModelProviderService modelProviderService,
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
@ -78,7 +75,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
}
/**
* RFC-03 Lane B1 overload accepts a per-model read-timeout override
* Overload accepts a per-model read-timeout override
* (seconds). Null falls back to the default 180s.
*/
AnthropicApi buildAnthropicApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
@ -108,8 +105,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
}
/**
* Substrings used to detect Claude 4.7 model variants. Reference:
* hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}.
* Substrings used to detect Claude 4.7 model variants.
* Claude 4.7 returns HTTP 400 if any of {@code temperature}, {@code top_p},
* or {@code top_k} are set to non-default values, AND introduces an
* "xhigh" thinking effort level between high and max.
@ -173,7 +169,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
log.debug("Ignoring temperature/top_p for Claude 4.7 model {} (API rejects sampling params)",
modelName);
}
// RFC-025: Anthropic rejects non-positive maxTokens clamp here so a bad config
// Anthropic rejects non-positive maxTokens clamp here so a bad config
// surfaces as a logged warning instead of an opaque API 400 mid-conversation.
Integer configuredMax = runtimeModel.getMaxTokens();
if (configuredMax != null && configuredMax > 0) {
@ -186,7 +182,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
builder.maxTokens(4096);
}
}
// RFC-014: prompt cache (system / tools / conversation history) Spring AI 1.1.4+ first-class.
// Prompt cache (system / tools / conversation history) Spring AI 1.1.4+ first-class.
builder.cacheOptions(anthropicCacheOptionsFactory.build());
return builder.internalToolExecutionEnabled(false).build();
@ -197,16 +193,16 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
* where nginx caps the gateway at 60s but a real long thinking response
* needs more the upper retry layer takes over once we time out.
*
* <p>Package-private + static so {@code AgentClaudeCodeChatModelBuilder}
* (RFC-062) can apply the same timeouts to its OAuth RestClient without
* duplicating the snippet.</p>
* <p>Package-private + static so {@code ClaudeCodeChatModelBuilder} can
* apply the same timeouts to its OAuth RestClient without duplicating
* the snippet.</p>
*/
static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
return applyHttpTimeouts(builder, null);
}
/**
* RFC-03 Lane B1 overload accepts a per-model read-timeout override
* Overload accepts a per-model read-timeout override
* (seconds). Null / zero / negative falls back to {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_READ_TIMEOUT}
* so unset model configs keep the historical 180s.
*/
@ -226,16 +222,16 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
* stalled provider could hang the agent thread indefinitely while the
* failover chain idles (no exception = no signal).
* <p>
* Mirrors AgentGraphBuilder.applyHttpTimeoutsToWebClient: same JDK
* HttpClient + JdkClientHttpConnector path, so the dependency surface
* doesn't pull in reactor-netty (excluded by this project's pom).
* Uses the same JDK HttpClient + JdkClientHttpConnector path, so the
* dependency surface doesn't pull in reactor-netty (excluded by this
* project's pom).
*/
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
return applyHttpTimeoutsToWebClient(builder, null);
}
/**
* RFC-03 Lane B1 overload same per-model override semantics as
* Overload same per-model override semantics as
* {@link #applyHttpTimeouts(RestClient.Builder, Integer)}.
*/
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {

View File

@ -1,4 +1,4 @@
package vip.mate.agent;
package vip.mate.llm.chatmodel;
import java.util.List;
import java.util.UUID;
@ -7,8 +7,8 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* Relays per-request assistant {@code reasoning_content} from the producer
* ({@code NodeStreamingChatHelper}, which sees {@code AssistantMessage.metadata})
* to the consumer ({@code AgentGraphBuilder.patchReasoningContent}, which rebuilds
* the outbound {@code ChatCompletionRequest}).
* to the consumer ({@link OpenAiRequestRewriter#patchReasoningContent}, which
* rebuilds the outbound {@code ChatCompletionRequest}).
*
* <p>Why not {@link ThreadLocal}: {@code OpenAiChatModel.stream()} hops to
* {@code boundedElastic} via {@code subscribeOn}, so a {@code ThreadLocal} on the

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.observation.ObservationRegistry;
@ -16,13 +16,12 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import vip.mate.llm.anthropic.oauth.ClaudeCodeApiHeaders;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* RFC-062: Strategy implementation for {@link ModelProtocol#ANTHROPIC_CLAUDE_CODE}.
* Strategy implementation for {@link ModelProtocol#ANTHROPIC_CLAUDE_CODE}.
*
* <p>Sends Anthropic Messages API requests authenticated with the user's
* Claude Code OAuth subscription token instead of an API key letting users
@ -56,9 +55,9 @@ import vip.mate.llm.model.ModelProviderEntity;
*/
@Slf4j
@Component
public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
public class ClaudeCodeChatModelBuilder implements ChatModelBuilder {
private final AgentAnthropicChatModelBuilder anthropicBuilder;
private final AnthropicChatModelBuilder anthropicBuilder;
private final ClaudeCodeOAuthService oauthService;
private final ClaudeCodeApiHeaders apiHeaders;
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
@ -66,8 +65,8 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
private final ObjectMapper objectMapper;
public AgentClaudeCodeChatModelBuilder(
AgentAnthropicChatModelBuilder anthropicBuilder,
public ClaudeCodeChatModelBuilder(
AnthropicChatModelBuilder anthropicBuilder,
ClaudeCodeOAuthService oauthService,
ClaudeCodeApiHeaders apiHeaders,
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
@ -126,7 +125,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
}
/**
* RFC-03 Lane B1 overload same OAuth-stamped Anthropic client, with a
* Overload same OAuth-stamped Anthropic client, with a
* per-model read-timeout override threaded through to the underlying
* RestClient + WebClient timeouts.
*/
@ -141,9 +140,8 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
// `anthropic-dangerous-direct-browser-access: true` on every request.
// Spring AI's Java client doesn't, so Anthropic's edge fingerprint
// sees the missing headers and treats the traffic as suspicious
// rate-limited harder than spec'd. Reference: openclaw
// anthropic-transport-stream.ts:567-574.
RestClient.Builder restClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeouts(
// rate-limited harder than spec'd.
RestClient.Builder restClientBuilder = AnthropicChatModelBuilder.applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
@ -161,7 +159,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
// staring at SDK internals.
.requestInterceptor(new RateLimitDiagnosticInterceptor());
WebClient.Builder webClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient(
WebClient.Builder webClientBuilder = AnthropicChatModelBuilder.applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.anthropic.AnthropicChatOptions;
@ -24,8 +24,8 @@ import java.util.List;
import java.util.Set;
/**
* RFC-062: Claude Code OAuth identity transform applied to every Anthropic
* request when the underlying auth is a Claude Code OAuth token.
* Claude Code OAuth identity transform applied to every Anthropic request
* when the underlying auth is a Claude Code OAuth token.
*
* <p>Anthropic's OAuth edge enforces an anti-abuse path that rate-limits
* (and intermittently 5xxs) requests claiming Claude Code identity but
@ -39,10 +39,6 @@ import java.util.Set;
* <li>Sporadic 500s on the first call after a long idle period.</li>
* </ul>
*
* <p>Reference: hermes-agent {@code anthropic_adapter._build_anthropic_messages_request}
* lines 1571-1607 same transforms applied unconditionally on
* {@code is_oauth=True} requests.
*
* <h2>Transforms applied per call</h2>
* <ol>
* <li><b>System prompt prefix</b>: prepend

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
@ -12,7 +12,6 @@ import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
@ -26,8 +25,8 @@ import java.util.Map;
*
* <p>Owns all DashScope-specific construction logic (api + options) plus the
* fallback-chain helpers for resolving API key / Base URL when the provider
* row is incomplete. PR-0b moved this code out of {@code AgentGraphBuilder}
* so the agent package no longer carries any DashScope schema knowledge.</p>
* row is incomplete, so the agent package no longer carries any DashScope
* schema knowledge.</p>
*
* <p>DashScopeChatModel is injected via ObjectProvider so that the builder
* degrades gracefully when DashScope auto-configuration is disabled or the
@ -35,13 +34,13 @@ import java.util.Map;
*/
@Slf4j
@Component
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
public class DashScopeChatModelBuilder implements ChatModelBuilder {
private final ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider;
private final DashScopeConnectionProperties dashScopeConnectionProperties;
private final ModelProviderService modelProviderService;
public AgentDashScopeChatModelBuilder(ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider,
public DashScopeChatModelBuilder(ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider,
DashScopeConnectionProperties dashScopeConnectionProperties,
ModelProviderService modelProviderService) {
this.dashScopeChatModelProvider = dashScopeChatModelProvider;

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
@ -10,7 +10,6 @@ import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import reactor.core.publisher.Flux;
import vip.mate.agent.ThinkingLevelHolder;
import java.util.ArrayList;
import java.util.HashMap;
@ -19,7 +18,7 @@ import java.util.List;
import java.util.Map;
/**
* RFC: DeepSeek V4 thinking-mode payload patcher applied to every
* DeepSeek V4 thinking-mode payload patcher applied to every
* {@code deepseek-v4-flash} / {@code deepseek-v4-pro} request.
*
* <p>DeepSeek V4 extends OpenAI's chat-completions wire format with two
@ -40,13 +39,10 @@ import java.util.Map;
* {@code reasoning_content} must be stripped or DeepSeek echoes the old
* thinking back into the response.
*
* <p>Reference: openclaw {@code plugin-sdk/provider-stream-shared.ts}
* lines 185-213 ({@code createDeepSeekV4OpenAICompatibleThinkingWrapper}).
*
* <h2>Pipeline (per request)</h2>
* <ol>
* <li>Read {@link ThinkingLevelHolder} for the current request's thinking
* level (set by AgentService before the call).</li>
* level (set by the agent service before the call).</li>
* <li>Clone {@link OpenAiChatOptions} and patch its {@code extraBody} +
* {@code reasoningEffort} fields. Spring AI sends {@code extraBody}
* verbatim in the JSON body, so the {@code thinking} key lands where
@ -57,10 +53,6 @@ import java.util.Map;
* entry to satisfy V4's replay contract.</li>
* <li>Delegate to the wrapped {@link ChatModel}.</li>
* </ol>
*
* <p>Spring AI 1.1.4's {@link OpenAiChatOptions} exposes a public
* {@code extraBody: Map<String, Object>} (verified via {@code javap}). No
* byte-level body patching needed the simple path works.
*/
@Slf4j
public class DeepSeekV4ThinkingDecorator implements ChatModel {
@ -116,9 +108,8 @@ public class DeepSeekV4ThinkingDecorator implements ChatModel {
/**
* Map MateClaw's thinking levels (off/low/medium/high/max) to DeepSeek's
* accepted reasoning_effort values. Aligns with openclaw
* {@code resolveDeepSeekV4ReasoningEffort}: max collapses into high since
* DeepSeek doesn't expose a "max" tier on V4.
* accepted reasoning_effort values: max collapses into high since DeepSeek
* does not expose a "max" tier on V4.
*/
static String mapEffort(String level) {
if (level == null || level.isBlank()) return "medium";

View File

@ -3,18 +3,13 @@ package vip.mate.llm.chatmodel;
import java.time.Duration;
/**
* RFC-03 Lane B1 central resolver for the per-LLM-request HTTP read
* timeout, so {@link vip.mate.llm.model.ModelConfigEntity#getRequestTimeoutSeconds()}
* can override the legacy 180s default without each chatmodel builder
* inventing its own fallback chain.
* Central resolver for the per-LLM-request HTTP read timeout, so
* {@link vip.mate.llm.model.ModelConfigEntity#getRequestTimeoutSeconds()}
* can override the default 180s without each chatmodel builder inventing
* its own fallback chain.
*
* <p>Used by:
* <ul>
* <li>{@code AgentAnthropicChatModelBuilder.applyHttpTimeouts}</li>
* <li>{@code AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient}</li>
* <li>{@code AgentClaudeCodeChatModelBuilder} (via Anthropic helper)</li>
* <li>{@code AgentGraphBuilder} legacy timeout helpers</li>
* </ul>
* <p>Used by the OpenAI-compatible, Anthropic and Claude Code chat model
* builders to apply consistent connect / read timeouts.
*
* <p>Connect timeout stays at the canonical 10s long-tail thinking
* latency manifests on the read path, not on connect.
@ -27,7 +22,7 @@ public final class HttpTimeouts {
/**
* Default read timeout when no per-model override is set. Matches the
* historical hardcoded value so unset rows behave identically to the
* pre-RFC-03 baseline.
* earlier baseline.
*/
public static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(180);

View File

@ -0,0 +1,472 @@
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.observation.ObservationRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.ApiKey;
import org.springframework.ai.model.NoopApiKey;
import org.springframework.ai.model.SimpleApiKey;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Flux;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import java.net.http.HttpClient;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Strategy implementation of {@link ChatModelBuilder} for
* {@link ModelProtocol#OPENAI_COMPATIBLE}.
*
* <p>Owns the full OpenAI-compatible construction path: the {@link OpenAiApi}
* client (HTTP timeouts, header overrides, completions-path resolution), the
* {@link OpenAiChatOptions} (temperature / max tokens / reasoning effort / web
* search), and the outbound request-rewrite pipeline delegated to
* {@link OpenAiRequestRewriter}. DeepSeek V4 reasoning models are wrapped with
* {@link DeepSeekV4ThinkingDecorator}.
*
* <p>Depends only on infrastructure beans, so the {@code llm} package builds a
* {@link ChatModel} without any dependency on the agent graph layer.
*/
@Slf4j
@Component
public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
private final ModelProviderService modelProviderService;
private final ObjectMapper objectMapper;
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
public OpenAiCompatibleChatModelBuilder(
ModelProviderService modelProviderService,
ObjectMapper objectMapper,
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
this.modelProviderService = modelProviderService;
this.objectMapper = objectMapper;
this.restClientBuilderProvider = restClientBuilderProvider;
this.webClientBuilderProvider = webClientBuilderProvider;
this.observationRegistryProvider = observationRegistryProvider;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.OPENAI_COMPATIBLE;
}
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
// Pass model.requestTimeoutSeconds so providers / models with
// extended-thinking p99s don't false-positive on the default read timeout.
OpenAiApi api = buildOpenAiApi(provider, model.getRequestTimeoutSeconds());
OpenAiChatOptions options = buildOpenAiOptions(model, provider);
ChatModel raw = OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
// DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
// strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
// those directly wrap with a per-request payload patcher.
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
return new DeepSeekV4ThinkingDecorator(raw);
}
return raw;
}
// ==================== chat options ====================
OpenAiChatOptions buildOpenAiOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
OpenAiChatOptions.Builder builder = OpenAiChatOptions.builder();
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
String modelName = runtimeModel.getModelName();
ModelFamily family = ModelFamily.detect(modelName);
if (StringUtils.hasText(modelName)) {
builder.model(modelName);
}
// temperature: some model families force 1.0
Double temperature = resolveOpenAiTemperature(modelName, runtimeModel.getTemperature(), kwargs, family);
if (temperature != null) {
builder.temperature(temperature);
}
// max_tokens / max_completion_tokens: routed by model family
if (family.suppressMaxTokens()) {
// OPENAI_REASONING family: max_tokens forbidden, use max_completion_tokens.
// fallback priority: kwargs.maxCompletionTokens > kwargs.maxTokens > config.maxTokens
Integer kwargsMaxTokens = ProviderGenerateKwargs.resolveIntegerOption(
"maxTokens", runtimeModel.getMaxTokens(), kwargs);
Integer maxCompletionTokens = ProviderGenerateKwargs.resolveIntegerOption(
"maxCompletionTokens", kwargsMaxTokens, kwargs);
if (maxCompletionTokens != null) {
builder.maxCompletionTokens(maxCompletionTokens);
}
log.debug("ModelFamily {} suppressed max_tokens, using max_completion_tokens={} for model {}",
family, maxCompletionTokens, modelName);
} else {
// Other model families: use max_tokens normally
Integer maxTokens = ProviderGenerateKwargs.resolveIntegerOption(
"maxTokens", runtimeModel.getMaxTokens(), kwargs);
if (maxTokens != null) {
builder.maxTokens(maxTokens);
}
// Still allow maxCompletionTokens to be set explicitly via generateKwargs
Integer maxCompletionTokens = ProviderGenerateKwargs.resolveIntegerOption(
"maxCompletionTokens", null, kwargs);
if (maxCompletionTokens != null) {
builder.maxCompletionTokens(maxCompletionTokens);
}
}
// top_p: forbidden for some model families
Double topP = resolveOpenAiTopP(modelName, runtimeModel.getTopP(), kwargs, family);
if (topP != null) {
builder.topP(topP);
}
// reasoning_effort: injected only for supporting model families
String reasoningEffort = ReasoningEffortResolver.resolveReasoningEffort(modelName, kwargs, family);
if (StringUtils.hasText(reasoningEffort)) {
builder.reasoningEffort(reasoningEffort);
}
// built-in search: model-level field wins, provider generateKwargs as fallback
boolean searchEnabled = Boolean.TRUE.equals(runtimeModel.getEnableSearch())
|| Boolean.TRUE.equals(kwargs.get("enableSearch"));
if (searchEnabled) {
String strategy = runtimeModel.getSearchStrategy();
if (!StringUtils.hasText(strategy)) {
strategy = (String) kwargs.get("searchStrategy");
}
OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize contextSize;
try {
contextSize = StringUtils.hasText(strategy)
? OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize.valueOf(strategy.toUpperCase())
: OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize.MEDIUM;
} catch (IllegalArgumentException e) {
contextSize = OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize.MEDIUM;
}
builder.webSearchOptions(new OpenAiApi.ChatCompletionRequest.WebSearchOptions(contextSize, null));
}
OpenAiChatOptions options = builder.build();
options.setInternalToolExecutionEnabled(false);
// Do not set parallelToolCalls setting it to false makes OpenAI return 400 when
// there are no tools: "parallel_tool_calls is only allowed when 'tools' are specified".
// Leaving it null keeps Spring AI from serializing the field; each node controls it
// when tools are present.
options.setStreamUsage(true);
return options;
}
private Double resolveOpenAiTemperature(String modelName, Double configuredTemperature,
Map<String, Object> kwargs, ModelFamily family) {
Double overriddenTemperature = ProviderGenerateKwargs.resolveDoubleOption(
"temperature", configuredTemperature, kwargs);
if (family.fixedTemperatureOne()) {
if (overriddenTemperature == null || Double.compare(overriddenTemperature, 1.0d) != 0) {
log.info("ModelFamily {} forced temperature=1.0 for model {}", family, modelName);
}
return 1.0d;
}
return overriddenTemperature;
}
private Double resolveOpenAiTopP(String modelName, Double configuredTopP,
Map<String, Object> kwargs, ModelFamily family) {
if (family.suppressTopP()) {
return null;
}
return ProviderGenerateKwargs.resolveDoubleOption("topP", configuredTopP, kwargs);
}
// ==================== OpenAI API client ====================
/**
* Build an {@link OpenAiApi} for the provider. Accepts a per-model
* read-timeout override (seconds), threaded into both the sync RestClient and
* the streaming WebClient. Null falls back to the default 180s.
*/
OpenAiApi buildOpenAiApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.agent.provider_not_configured",
"Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
}
String apiKey = provider.getApiKey();
// Honor the provider's requireApiKey flag instead of hard-failing on every empty key.
// Local + key-free providers (Ollama, LM Studio, MLX, llama.cpp, OpenCode) declare
// requireApiKey=false; for them an empty / placeholder key means "no Authorization
// header" — Spring AI's NoopApiKey expresses that.
boolean keyRequired = !Boolean.FALSE.equals(provider.getRequireApiKey());
if (keyRequired && !modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.agent.provider_apikey_invalid",
"Provider API Key 未配置或无效: " + provider.getProviderId());
}
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
if (!StringUtils.hasText(baseUrl)) {
throw new MateClawException("err.agent.provider_baseurl_missing",
"Provider Base URL 未配置: " + provider.getProviderId());
}
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
MultiValueMap<String, String> headers = buildOpenAiHeaders(kwargs);
String completionsPath = resolveOpenAiCompletionsPath(baseUrl, kwargs);
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
// 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
// that must masquerade as a specific client (e.g. kimi-code), force-override headers
// via a RestClient/WebClient interceptor before the request goes out.
Map<String, String> overrideHeaders = extractOverrideHeaders(kwargs);
if (!overrideHeaders.isEmpty()) {
restClientBuilder = restClientBuilder.requestInterceptor((request, body, execution) -> {
HttpHeaders reqHeaders = request.getHeaders();
overrideHeaders.forEach(reqHeaders::set);
return execution.execute(request, body);
});
webClientBuilder = webClientBuilder.filter((request, next) -> {
org.springframework.web.reactive.function.client.ClientRequest modified =
org.springframework.web.reactive.function.client.ClientRequest.from(request)
.headers(h -> overrideHeaders.forEach(h::set))
.build();
return next.exchange(modified);
});
}
boolean kimiSearchEnabled = isKimiProvider(provider)
&& Boolean.TRUE.equals(kwargs.get("enableSearch"));
ApiKey apiKeyImpl = (keyRequired && StringUtils.hasText(apiKey))
? new SimpleApiKey(apiKey.trim())
: new NoopApiKey();
return new OpenAiApi(
baseUrl,
apiKeyImpl,
headers,
completionsPath,
"/v1/embeddings",
restClientBuilder,
webClientBuilder,
RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER) {
@Override
public org.springframework.http.ResponseEntity<OpenAiApi.ChatCompletion> chatCompletionEntity(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = OpenAiRequestRewriter.stripAutoToolChoice(chatRequest);
chatRequest = OpenAiRequestRewriter.patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
chatRequest = OpenAiRequestRewriter.injectKimiWebSearch(chatRequest);
}
logOpenAiRequest(provider, chatRequest);
try {
return super.chatCompletionEntity(chatRequest, additionalHttpHeader);
} catch (WebClientResponseException e) {
logOpenAiError(provider, e);
throw e;
}
}
@Override
public Flux<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = OpenAiRequestRewriter.stripAutoToolChoice(chatRequest);
chatRequest = OpenAiRequestRewriter.patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
chatRequest = OpenAiRequestRewriter.injectKimiWebSearch(chatRequest);
}
logOpenAiRequest(provider, chatRequest);
return super.chatCompletionStream(chatRequest, additionalHttpHeader)
.doOnError(error -> {
if (error instanceof WebClientResponseException e) {
logOpenAiError(provider, e);
}
});
}
};
}
/**
* Whether the provider is one of Kimi's first-party providers. Public so the
* agent graph builder can surface a "built-in search active" log line.
*/
public static boolean isKimiProvider(ModelProviderEntity provider) {
if (provider == null) return false;
String id = provider.getProviderId();
return "kimi-cn".equals(id) || "kimi-intl".equals(id);
}
// ==================== URL / headers ====================
private String normalizeOpenAiBaseUrl(String baseUrl) {
if (!StringUtils.hasText(baseUrl)) {
return null;
}
String normalized = baseUrl.trim();
if (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
if (normalized.endsWith("/v1")) {
normalized = normalized.substring(0, normalized.length() - 3);
}
return normalized;
}
private MultiValueMap<String, String> buildOpenAiHeaders(Map<String, Object> kwargs) {
LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add("User-Agent", "MateClaw/1.0");
Object headerObject = kwargs.get("headers");
if (headerObject instanceof Map<?, ?> headerMap) {
headerMap.forEach((key, value) -> {
if (key != null && value != null) {
headers.set(String.valueOf(key), String.valueOf(value));
}
});
}
return headers;
}
/**
* Extract headers that must be force-overridden, read from
* {@code generateKwargs.headers}. Used by a RestClient/WebClient interceptor
* to bypass Spring AI's default User-Agent.
*/
private Map<String, String> extractOverrideHeaders(Map<String, Object> kwargs) {
Map<String, String> result = new HashMap<>();
Object headerObject = kwargs.get("headers");
if (headerObject instanceof Map<?, ?> headerMap) {
headerMap.forEach((key, value) -> {
if (key != null && value != null) {
result.put(String.valueOf(key), String.valueOf(value));
}
});
}
return result;
}
// Trailing "/v{digits}" segment in a base URL the OpenAI-compatible convention
// (/v1 OpenAI, /v3 Volcano Ark, /v4 Zhipu). When the baseUrl already carries this
// segment, the default /v1 prefix on the path must be stripped to avoid building
// a broken URL like /api/v3/v1/chat/completions.
private static final Pattern OPENAI_BASE_URL_VERSION_SUFFIX = Pattern.compile(".*/v\\d+$");
private String resolveOpenAiCompletionsPath(String baseUrl, Map<String, Object> kwargs) {
Object raw = kwargs.get("completionsPath");
boolean explicit = raw instanceof String value && StringUtils.hasText(value);
String path = explicit ? ((String) raw).trim() : "/v1/chat/completions";
if (!path.startsWith("/")) {
path = "/" + path;
}
// An explicit completionsPath is honored as-is. Otherwise, dedupe the /v1
// prefix when the baseUrl already ends with /v{N} (Volcano Engine Ark /v3,
// Zhipu /v4, etc.).
if (!explicit
&& baseUrl != null
&& OPENAI_BASE_URL_VERSION_SUFFIX.matcher(baseUrl).matches()
&& path.startsWith("/v1/")) {
path = path.substring(3);
}
return path;
}
// ==================== HTTP timeouts ====================
/**
* Configure an explicit timeout on the RestClient used for LLM calls so a
* socket never hangs forever.
*
* <p>Uses {@link JdkClientHttpRequestFactory} (backed by the Java 11+
* {@link HttpClient}) because it natively supports HTTP/2 / ALPN negotiation
* and transparently decompresses {@code Content-Encoding: gzip} responses.
*
* <p>connectTimeout=10s; readTimeout defaults to 180s (covers an nginx 60s
* gateway timeout plus headroom for a real long response; the upper retry
* layer takes over once it times out).
*/
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1)
.build();
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
rf.setReadTimeout(HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.requestFactory(rf);
}
/**
* Apply equivalent timeouts to the WebClient backing OpenAI-compatible
* STREAMING calls. Without this the streaming path uses a default WebClient
* with neither connect nor read timeout, so a stalled provider can hang the
* call indefinitely while the failover chain idles (no exception thrown).
*
* <p>Uses {@link org.springframework.http.client.reactive.JdkClientHttpConnector}
* with the same {@link HttpClient} so the dependency surface stays clean
* (reactor-netty is not on this project's classpath).
*/
private WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
// Pin HTTP/1.1: many self-hosted OpenAI-compatible servers (vLLM, lmstudio,
// llama.cpp, ollama all uvicorn/ASGI based) only speak HTTP/1.1 over
// cleartext and slam the socket on the JDK client's default H2C upgrade
// probe, surfacing as "header parser received no bytes" with no body sent.
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1)
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
connector.setReadTimeout(HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.clientConnector(connector);
}
// ==================== logging ====================
private void logOpenAiRequest(ModelProviderEntity provider, OpenAiApi.ChatCompletionRequest chatRequest) {
try {
log.info("OpenAI-compatible request: provider={}, body={}",
provider.getProviderId(), objectMapper.writeValueAsString(chatRequest));
} catch (Exception e) {
log.warn("Failed to serialize OpenAI-compatible request for {}: {}",
provider.getProviderId(), e.getMessage());
}
}
private void logOpenAiError(ModelProviderEntity provider, WebClientResponseException e) {
log.error("OpenAI-compatible error: provider={}, status={}, body={}",
provider.getProviderId(), e.getStatusCode(), e.getResponseBodyAsString());
}
}

View File

@ -0,0 +1,736 @@
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.openai.api.OpenAiApi;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProviderEntity;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Provider-aware rewrites applied to an outbound OpenAI-compatible
* {@link OpenAiApi.ChatCompletionRequest} just before it hits the wire.
*
* <p>{@link OpenAiCompatibleChatModelBuilder} runs these in a fixed order on both
* the blocking and streaming chat-completion paths. Each method is a pure
* transformation: it returns the original request unchanged when it has nothing
* to do, or a rebuilt request (the Spring AI record is immutable) otherwise.
*
* <p>The rewrites exist because OpenAI-compatible providers diverge in ways
* Spring AI's {@code OpenAiChatOptions} cannot express reasoning-content
* replay contracts, reasoning-effort acceptance, strict tool-choice validation,
* video media encoding, and Kimi's built-in web search tool.
*/
@Slf4j
final class OpenAiRequestRewriter {
private OpenAiRequestRewriter() {}
// ==================== reasoning_content patching ====================
/**
* Consume the {@link AssistantThinkingRelay} entry and rebuild the outbound
* request so assistant tool-call / thinking messages carry the correct
* {@code reasoning_content}.
*
* <p>This is the consumer side of the relay. The producer
* ({@code NodeStreamingChatHelper}) stashes per-assistant thinking keyed on a
* token embedded in {@code request.user()}. Here we:
* <ol>
* <li>{@link AssistantThinkingRelay#take(String)} the entry and restore
* {@code request.user()} to {@code entry.originalUser()} so the
* internal token never reaches the provider.</li>
* <li>Compute {@code lastUserIdx} (the boundary of the current user turn).
* Assistant messages at {@code i <= lastUserIdx} are prior-turn history:
* their {@code reasoning_content} normally stays null. Only
* {@code i > lastUserIdx} messages are eligible for patching, unless the
* provider policy opts into cross-turn patching.</li>
* <li>Select a {@link FallbackPolicy} by {@code providerId}. When the relay
* has a real value we use it; when empty, the policy decides whether to
* inject {@code " "} (legacy tolerance) or leave {@code null} to surface
* an explicit provider error.</li>
* </ol>
*
* <p>The relay iterator advances for every assistant message (including
* prior-turn ones) to stay positionally aligned with the producer's extraction.
*/
static OpenAiApi.ChatCompletionRequest patchReasoningContent(
OpenAiApi.ChatCompletionRequest request, ModelProviderEntity provider) {
if (request.messages() == null || request.messages().isEmpty()) {
return request;
}
// 1. Consume relay (if any) and compute the sanitized user field.
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(request.user());
String sanitizedUser = (entry != null)
? entry.originalUser()
: (AssistantThinkingRelay.isToken(request.user()) ? null : request.user());
// 2. Detect thinking mode relay presence is also a trigger.
boolean thinkingMode = request.reasoningEffort() != null
|| requiresReasoningContentPatch(request.model())
|| request.messages().stream().anyMatch(m ->
m.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
&& m.reasoningContent() != null)
|| entry != null;
if (!thinkingMode) {
// Nothing to patch but we may still need to strip a leaked relay token from user.
return request.user() != null && !request.user().equals(sanitizedUser)
? rebuildWithUser(request, sanitizedUser)
: request;
}
// 3. Find lastUserIdx so we can skip cross-turn assistants.
int lastUserIdx = -1;
for (int i = request.messages().size() - 1; i >= 0; i--) {
if (request.messages().get(i).role() == OpenAiApi.ChatCompletionMessage.Role.USER) {
lastUserIdx = i;
break;
}
}
FallbackPolicy policy = FallbackPolicy.forProvider(provider);
java.util.Iterator<String> it = (entry != null)
? entry.thinkings().iterator()
: java.util.Collections.emptyIterator();
// 4. Walk messages, patching only in-turn assistants; always advance iterator
// for all assistants so producer/consumer positions stay aligned.
boolean anyPatched = false;
List<OpenAiApi.ChatCompletionMessage> patched = new ArrayList<>(request.messages().size());
for (int i = 0; i < request.messages().size(); i++) {
OpenAiApi.ChatCompletionMessage msg = request.messages().get(i);
if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.ASSISTANT) {
patched.add(msg);
continue;
}
String next = it.hasNext() ? it.next() : null;
// Already has a real value: leave alone
if (msg.reasoningContent() != null && !msg.reasoningContent().isBlank()) {
patched.add(msg);
continue;
}
// Cross-turn assistant: usually skipped, since thinking resets across
// user turns. But some providers require reasoning_content even on
// prior-turn assistants and reject requests where any prior assistant
// has it null. For policies with patchCrossTurn=true, fall through and
// patch with the empty fallback (" ") so multi-turn conversations
// don't 400.
if (i <= lastUserIdx && !policy.patchCrossTurn) {
patched.add(msg);
continue;
}
boolean hasToolCalls = msg.toolCalls() != null && !msg.toolCalls().isEmpty();
if (!hasToolCalls && !policy.patchNonToolCall) {
patched.add(msg);
continue;
}
String injected;
if (next != null && !next.isEmpty()) {
injected = next;
} else {
injected = policy.emptyFallback;
if (injected == null && policy.warnOnMissingReal) {
log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
+ "but relay has no value for assistant message at index {}; "
+ "leaving null so provider returns explicit error.",
providerIdOrUnknown(provider), i);
}
}
if (injected == null && msg.reasoningContent() == null) {
// No change keep original
patched.add(msg);
continue;
}
patched.add(new OpenAiApi.ChatCompletionMessage(
msg.rawContent(), msg.role(), msg.name(), msg.toolCallId(),
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
msg.annotations(), injected));
anyPatched = true;
}
boolean userChanged = request.user() != null && !request.user().equals(sanitizedUser)
|| (request.user() == null && sanitizedUser != null);
if (!anyPatched && !userChanged) {
return request;
}
// 5. Rebuild with patched messages + sanitized user.
return new OpenAiApi.ChatCompletionRequest(
patched,
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
sanitizedUser,
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
/**
* Provider-keyed policy for how {@link #patchReasoningContent} behaves when
* the relay has no real thinking for an in-turn assistant message.
*
* <ul>
* <li>{@code emptyFallback}: value to inject when the relay has no real
* value {@code null} means leave {@code reasoning_content} null;
* {@code " "} preserves legacy tolerance.</li>
* <li>{@code warnOnMissingReal}: emit WARN when {@code emptyFallback==null}
* fires.</li>
* <li>{@code patchNonToolCall}: whether to patch assistant messages without
* tool_calls. DeepSeek's contract applies to all in-turn assistant
* messages; others only to tool_call messages.</li>
* <li>{@code patchCrossTurn}: whether to also patch prior-turn assistants
* ({@code i <= lastUserIdx}). DeepSeek requires reasoning_content on
* every assistant message in the request, including prior-turn history,
* and MateClaw does not persist reasoning_content so cross-turn
* patching keeps multi-turn conversations from 400-ing.</li>
* </ul>
*
* <p>{@code DEFAULT} keeps the legacy {@code " "} tolerance rather than going
* no-op: an unrecognized provider (self-hosted DeepSeek-like backend, custom
* OpenAI-compatible gateway) might still require the patch.
*/
private enum FallbackPolicy {
DEEPSEEK(" ", false, true, true),
KIMI (" ", false, false, false),
OPENAI (" ", false, false, false),
DEFAULT (" ", false, false, false);
final String emptyFallback;
final boolean warnOnMissingReal;
final boolean patchNonToolCall;
/** Whether to also patch prior-turn assistants ({@code i <= lastUserIdx}). */
final boolean patchCrossTurn;
FallbackPolicy(String emptyFallback, boolean warnOnMissingReal,
boolean patchNonToolCall, boolean patchCrossTurn) {
this.emptyFallback = emptyFallback;
this.warnOnMissingReal = warnOnMissingReal;
this.patchNonToolCall = patchNonToolCall;
this.patchCrossTurn = patchCrossTurn;
}
static FallbackPolicy forProvider(ModelProviderEntity provider) {
if (provider == null || provider.getProviderId() == null) {
return DEFAULT;
}
String id = provider.getProviderId().toLowerCase();
return switch (id) {
case "deepseek" -> DEEPSEEK;
case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI;
case "openai", "azure-openai" -> OPENAI;
default -> DEFAULT;
};
}
}
/**
* Rebuild a request with only the {@code user} field replaced. Used when
* {@link #patchReasoningContent} has no assistant-message changes but must
* strip a relay token from the outbound {@code user} field.
*/
private static OpenAiApi.ChatCompletionRequest rebuildWithUser(
OpenAiApi.ChatCompletionRequest request, String newUser) {
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
newUser,
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
private static boolean requiresReasoningContentPatch(String modelName) {
ModelFamily family = ModelFamily.detect(modelName);
return family.isThinking();
}
// ==================== reasoning_effort sanitizing ====================
/**
* Provider-first sanitization of {@code reasoning_effort}.
*
* <p>Authoritative judgement uses {@code provider.getProviderId()} as a
* whitelist (default-deny). Only official OpenAI providers may carry
* {@code reasoning_effort}; everything else known non-supporters and any
* unrecognized providerId (self-hosted gateways, aggregators) is stripped.
*
* <p>{@code request.model()} is intentionally distrusted here: the failover
* chain can reuse the same {@code OpenAiChatOptions} across providers, so a
* failover hop from a GPT-5 primary to DeepSeek would still carry model name
* "gpt-5". Checking only the model family would let the primary's
* {@code reasoning_effort} leak to DeepSeek.
*
* <p>Only when the provider is whitelisted do we fall through to the
* {@link ModelFamily} check.
*/
static OpenAiApi.ChatCompletionRequest sanitizeReasoningEffortForProvider(
OpenAiApi.ChatCompletionRequest request, ModelProviderEntity provider) {
if (request == null || request.reasoningEffort() == null) {
return request;
}
if (!isReasoningEffortWhitelistedProvider(provider)) {
log.warn("[reasoning_effort sanitizer] provider={} is not on the reasoning_effort "
+ "whitelist (only openai/azure-openai are); stripping value='{}' "
+ "(request.model()='{}' may be leaked from failover primary).",
providerIdOrUnknown(provider), request.reasoningEffort(), request.model());
return rebuildWithReasoningEffort(request, null);
}
ModelFamily targetFamily = ModelFamily.detect(request.model());
if (!targetFamily.supportsReasoningEffort()) {
log.warn("[reasoning_effort sanitizer] provider={} model={} family={} does not "
+ "support reasoning_effort; stripping value='{}'.",
provider.getProviderId(), request.model(), targetFamily, request.reasoningEffort());
return rebuildWithReasoningEffort(request, null);
}
return request;
}
/**
* Whitelist of providers known to accept {@code reasoning_effort} on
* {@code /v1/chat/completions} (or {@code /v1/responses}). Anything else is
* denied. Adding a provider here must come with a corresponding test case.
*/
static boolean isReasoningEffortWhitelistedProvider(ModelProviderEntity provider) {
if (provider == null || provider.getProviderId() == null) {
return false;
}
String id = provider.getProviderId().toLowerCase();
return switch (id) {
case "openai", "azure-openai" -> true;
default -> false;
};
}
private static String providerIdOrUnknown(ModelProviderEntity p) {
return (p == null || p.getProviderId() == null) ? "<unknown>" : p.getProviderId();
}
/**
* Rebuild a request with a new {@code reasoningEffort} value (typically
* {@code null} to strip).
*/
private static OpenAiApi.ChatCompletionRequest rebuildWithReasoningEffort(
OpenAiApi.ChatCompletionRequest request, String newReasoningEffort) {
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
newReasoningEffort,
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
/**
* GPT-5 compatibility: on the {@code /v1/chat/completions} path, {@code tools}
* and {@code reasoning_effort} cannot both be present.
*
* <p>When a gpt-5* model carries both, {@code reasoning_effort} is removed and
* a warning is logged. To use {@code reasoning_effort}, switch to the
* {@code /v1/responses} endpoint via the {@code completionsPath} generate kwarg.
*/
static OpenAiApi.ChatCompletionRequest stripReasoningEffortIfIncompatible(
OpenAiApi.ChatCompletionRequest request) {
if (request.reasoningEffort() == null) {
return request;
}
if (request.tools() == null || request.tools().isEmpty()) {
return request;
}
String model = request.model();
if (model == null || !model.trim().toLowerCase().startsWith("gpt-5")) {
return request;
}
log.warn("[GPT-5 compat] model {} carries both tools and reasoning_effort on "
+ "chat/completions; removing reasoning_effort to avoid a 400. "
+ "To use reasoning_effort, set completionsPath to /v1/responses",
model);
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
null, // reasoningEffort removed
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
// ==================== tool_choice / media ====================
/**
* Strip {@code tool_choice="auto"} from outbound requests.
*
* <p>Per the OpenAI spec, omitting {@code tool_choice} when {@code tools} is
* non-empty is equivalent to {@code "auto"}. Stripping the explicit literal:
* <ul>
* <li>does not change behavior on compliant servers they still default to
* auto when tools are present;</li>
* <li>unblocks strict OpenAI-compatible self-hosted serving frameworks that
* reject {@code tool_choice="auto"} at request validation time unless
* launched with an auto-tool-choice opt-in flag.</li>
* </ul>
*
* <p>Explicit values other than {@code "auto"} are passed through unchanged.
*/
static OpenAiApi.ChatCompletionRequest stripAutoToolChoice(OpenAiApi.ChatCompletionRequest request) {
Object tc = request.toolChoice();
if (tc == null || !"auto".equals(String.valueOf(tc))) {
return request;
}
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
null, // toolChoice strip "auto" so strict OpenAI-compatible servers accept the request
request.parallelToolCalls(),
request.user(),
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
/**
* Convert video content blocks that Spring AI mis-serializes as
* {@code image_url} into {@code video_url} format.
*
* <p>Spring AI's {@code MediaContent} has no video_url type, so every non-audio
* / non-pdf media block is serialized as {@code image_url}. Models such as
* Zhipu GLM-5V require video to use {@code video_url}; otherwise they report
* an image parse error. This walks user-message content and rewrites any
* {@code data:video/*} {@code image_url} into {@code video_url}.
*/
@SuppressWarnings("unchecked")
static OpenAiApi.ChatCompletionRequest patchVideoMediaContent(OpenAiApi.ChatCompletionRequest request) {
if (request.messages() == null || request.messages().isEmpty()) {
return request;
}
boolean needsPatch = false;
for (var msg : request.messages()) {
if (msg.role() == OpenAiApi.ChatCompletionMessage.Role.USER) {
Object raw = msg.rawContent();
if (raw instanceof List<?> parts) {
for (Object part : parts) {
// MediaContent record
if (part instanceof OpenAiApi.ChatCompletionMessage.MediaContent mc
&& "image_url".equals(mc.type())
&& mc.imageUrl() != null
&& mc.imageUrl().url() != null
&& mc.imageUrl().url().startsWith("data:video/")) {
needsPatch = true;
break;
}
// Map form (Spring AI represents content parts as LinkedHashMap internally)
if (part instanceof java.util.Map<?,?> map) {
Object type = map.get("type");
if ("image_url".equals(type)) {
Object imgUrlObj = map.get("image_url");
if (imgUrlObj instanceof java.util.Map<?,?> imgUrl) {
Object url = imgUrl.get("url");
if (url instanceof String urlStr && urlStr.startsWith("data:video/")) {
needsPatch = true;
break;
}
}
}
}
}
}
}
if (needsPatch) break;
}
if (!needsPatch) {
return request;
}
List<OpenAiApi.ChatCompletionMessage> patched = request.messages().stream().map(msg -> {
if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.USER || !(msg.rawContent() instanceof List<?> parts)) {
return msg;
}
List<Object> newParts = new ArrayList<>();
for (Object part : parts) {
String videoDataUrl = null;
// Case 1: MediaContent record (native Spring AI construction)
if (part instanceof OpenAiApi.ChatCompletionMessage.MediaContent mc
&& "image_url".equals(mc.type())
&& mc.imageUrl() != null && mc.imageUrl().url() != null
&& mc.imageUrl().url().startsWith("data:video/")) {
videoDataUrl = mc.imageUrl().url();
}
// Case 2: Map form (Jackson deserialization or Spring AI internal Map)
if (videoDataUrl == null && part instanceof java.util.Map<?,?> map
&& "image_url".equals(map.get("type"))) {
Object imgUrlObj = map.get("image_url");
if (imgUrlObj instanceof java.util.Map<?,?> imgUrl) {
Object url = imgUrl.get("url");
if (url instanceof String urlStr && urlStr.startsWith("data:video/")) {
videoDataUrl = urlStr;
}
}
}
if (videoDataUrl != null) {
// Rewrite to video_url format
newParts.add(Map.of(
"type", "video_url",
"video_url", Map.of("url", videoDataUrl)
));
} else {
newParts.add(part);
}
}
return new OpenAiApi.ChatCompletionMessage(
newParts, msg.role(), msg.name(), msg.toolCallId(),
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
msg.annotations(), msg.reasoningContent());
}).toList();
return new OpenAiApi.ChatCompletionRequest(
patched,
request.model(), request.store(), request.metadata(),
request.frequencyPenalty(), request.logitBias(),
request.logprobs(), request.topLogprobs(),
request.maxTokens(), request.maxCompletionTokens(),
request.n(), request.outputModalities(), request.audioParameters(),
request.presencePenalty(), request.responseFormat(),
request.seed(), request.serviceTier(), request.stop(),
request.stream(), request.streamOptions(),
request.temperature(), request.topP(),
request.tools(), request.toolChoice(), request.parallelToolCalls(),
request.user(), request.reasoningEffort(),
request.webSearchOptions(), request.verbosity(),
request.promptCacheKey(), request.safetyIdentifier(),
request.extraBody()
);
}
// ==================== Kimi built-in search ====================
/**
* Inject the {@code $web_search} built-in tool into a Kimi request.
*
* <p>Kimi's built-in search is enabled by declaring
* {@code {"type":"builtin_function","function":{"name":"$web_search"}}} in the
* tools array. Spring AI's {@code FunctionTool.Type} only has {@code FUNCTION},
* so this injects the raw JSON structure via {@code extraBody} overriding the
* tools field with the original tools plus {@code $web_search}.
*/
static OpenAiApi.ChatCompletionRequest injectKimiWebSearch(OpenAiApi.ChatCompletionRequest request) {
// Build the $web_search entry as a Map
Map<String, Object> webSearchTool = Map.of(
"type", "builtin_function",
"function", Map.of("name", "$web_search")
);
// Convert existing tools to List<Map> and append $web_search
List<Map<String, Object>> allTools = new ArrayList<>();
if (request.tools() != null) {
for (OpenAiApi.FunctionTool tool : request.tools()) {
Map<String, Object> toolMap = new LinkedHashMap<>();
toolMap.put("type", "function");
if (tool.getFunction() != null) {
Map<String, Object> funcMap = new LinkedHashMap<>();
funcMap.put("name", tool.getFunction().getName());
if (tool.getFunction().getDescription() != null) {
funcMap.put("description", tool.getFunction().getDescription());
}
if (tool.getFunction().getParameters() != null) {
funcMap.put("parameters", tool.getFunction().getParameters());
}
if (tool.getFunction().getStrict() != null) {
funcMap.put("strict", tool.getFunction().getStrict());
}
toolMap.put("function", funcMap);
}
allTools.add(toolMap);
}
}
allTools.add(webSearchTool);
// Inject tools via extraBody (overrides the tools field), and clear the
// original tools field to avoid duplicate serialization.
Map<String, Object> extraBody = new LinkedHashMap<>();
if (request.extraBody() != null) {
extraBody.putAll(request.extraBody());
}
extraBody.put("tools", allTools);
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
null, // tools cleared, extraBody takes over
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
extraBody
);
}
}

View File

@ -0,0 +1,89 @@
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.util.Map;
/**
* Reads typed values out of a provider's {@code generateKwargs} map.
*
* <p>A lookup tries the camelCase key first, then a snake_case fallback, and also
* descends into a nested {@code chatOptions} map so an admin may specify an
* option under any of those shapes. Shared by the OpenAI-compatible chat model
* builder and the reasoning-effort resolver.
*/
@Slf4j
public final class ProviderGenerateKwargs {
private ProviderGenerateKwargs() {}
/**
* Find a raw option value by key, trying the camelCase form then a
* snake_case fallback. Returns {@code null} when neither is present.
*/
public static Object findOptionValue(Map<String, Object> kwargs, String key) {
Object direct = findKwarg(kwargs, key);
if (direct != null) {
return direct;
}
String snakeCase = key.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
if (!snakeCase.equals(key)) {
return findKwarg(kwargs, snakeCase);
}
return null;
}
@SuppressWarnings("unchecked")
private static Object findKwarg(Map<String, Object> kwargs, String key) {
if (kwargs == null || kwargs.isEmpty()) {
return null;
}
if (kwargs.containsKey(key)) {
return kwargs.get(key);
}
Object chatOptions = kwargs.get("chatOptions");
if (chatOptions instanceof Map<?, ?> optionsMap) {
return ((Map<String, Object>) optionsMap).get(key);
}
return null;
}
/**
* Resolve a {@code Double} option, falling back to {@code fallback} when the
* key is absent or holds a non-numeric value.
*/
public static Double resolveDoubleOption(String key, Double fallback, Map<String, Object> kwargs) {
Object value = findOptionValue(kwargs, key);
if (value instanceof Number number) {
return number.doubleValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
try {
return Double.parseDouble(text.trim());
} catch (NumberFormatException ignored) {
log.warn("Invalid double generateKwargs value for {}: {}", key, text);
}
}
return fallback;
}
/**
* Resolve an {@code Integer} option, falling back to {@code fallback} when the
* key is absent or holds a non-numeric value.
*/
public static Integer resolveIntegerOption(String key, Integer fallback, Map<String, Object> kwargs) {
Object value = findOptionValue(kwargs, key);
if (value instanceof Number number) {
return number.intValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
try {
return Integer.parseInt(text.trim());
} catch (NumberFormatException ignored) {
log.warn("Invalid integer generateKwargs value for {}: {}", key, text);
}
}
return fallback;
}
}

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DataBuffer;

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;

View File

@ -0,0 +1,63 @@
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import vip.mate.llm.model.ModelFamily;
import java.util.Map;
/**
* Resolves the OpenAI-style {@code reasoning_effort} request parameter for a
* model, given its name, the provider's generate kwargs and its {@link ModelFamily}.
*
* <p>Resolution rules:
* <ul>
* <li>Families that do not accept {@code reasoning_effort} always resolve to
* {@code null}; an explicit kwargs override on such a model is dropped with
* a warning, because sending the field would 400 on DeepSeek / Kimi-style
* providers.</li>
* <li>For accepting families, an explicit {@code reasoningEffort} in the
* provider's generate kwargs wins.</li>
* <li>Otherwise a thinking-capable family gets a default of {@code "medium"}.</li>
* </ul>
*
* <p>Pure function with no Spring dependencies, so it is shared by the
* OpenAI-compatible chat model builder and the agent graph builder (which passes
* the resolved value to its reasoning nodes).
*/
@Slf4j
public final class ReasoningEffortResolver {
private ReasoningEffortResolver() {}
/**
* Resolve the effective {@code reasoning_effort} value, or {@code null} when
* the model must not carry one.
*/
public static String resolveReasoningEffort(String modelName, Map<String, Object> kwargs, ModelFamily family) {
// Only families that actually accept reasoning_effort may receive it.
// Otherwise a provider-level `reasoningEffort` override would leak to
// deepseek-chat / kimi-k2 / deepseek-reasoner etc., triggering a
// "reasoning_content missing" 400.
if (!family.supportsReasoningEffort()) {
Object overridden = ProviderGenerateKwargs.findOptionValue(kwargs, "reasoningEffort");
if (overridden != null) {
log.warn("Dropping reasoningEffort='{}' from generateKwargs — model '{}' (family={}) "
+ "does not accept reasoning_effort. For DeepSeek thinking use "
+ "extra_body.thinking; for Kimi thinking the model activates it natively.",
overridden, modelName, family);
}
return null;
}
// An explicit generateKwargs override always wins (within accepting families).
Object value = ProviderGenerateKwargs.findOptionValue(kwargs, "reasoningEffort");
if (value instanceof String text && StringUtils.hasText(text)) {
return text.trim();
}
// Only thinking-capable families get a default reasoning effort.
if (family.isThinking()) {
return "medium";
}
return null;
}
}

View File

@ -0,0 +1,36 @@
package vip.mate.llm.chatmodel;
/**
* Request-scoped {@link ThreadLocal} holder for the thinking depth.
*
* <p>Carries the front-end-selected thinking level from the agent service down
* to the reasoning nodes and chat model builders, without mutating the cached
* agent instance or the streaming interfaces.
*
* <p>Supported values: off / low / medium / high / max; {@code null} means
* "follow the model default".
*
* @author MateClaw Team
*/
public final class ThinkingLevelHolder {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
private ThinkingLevelHolder() {}
public static void set(String level) {
HOLDER.set(level);
}
/**
* Get the current request's thinking level; {@code null} means unset
* (follow the model default).
*/
public static String get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}

View File

@ -39,7 +39,7 @@ public class ModelConfigEntity {
* RFC-03 Lane B1 per-model HTTP read timeout (seconds).
*
* <p>Null / zero / negative fall back to the global default of 180s
* (existing behavior, see {@code AgentAnthropicChatModelBuilder.applyHttpTimeouts}
* (existing behavior, see {@code AnthropicChatModelBuilder.applyHttpTimeouts}
* and the corresponding helper in {@code AgentGraphBuilder}). A positive
* value overrides for this specific model.
*

View File

@ -53,7 +53,7 @@ public enum ModelFamily {
* 也不强制 temperature=1OpenClaw 实现参考 {@code extensions/deepseek/models.ts:28-81} 标记
* {@code supportsReasoningEffort: true}<br>
* 约束保留 max_tokens支持 reasoning_efforttemperature/topP 用配置值
* thinking=true {@link vip.mate.agent.chatmodel.DeepSeekV4ThinkingDecorator}
* thinking=true {@link vip.mate.llm.chatmodel.DeepSeekV4ThinkingDecorator}
* 在请求体注入 OpenAI 协议外的 {@code thinking: {type: enabled|disabled}} 字段
*/
DEEPSEEK_V4_REASONING(false, false, true, false, false, true),

View File

@ -10,7 +10,7 @@ public enum ModelProtocol {
/**
* RFC-062: same Anthropic Messages API but authenticated with the user's
* Claude Code OAuth token (Pro/Max subscription) instead of an API key.
* Routed by {@code AgentClaudeCodeChatModelBuilder}.
* Routed by {@code ClaudeCodeChatModelBuilder}.
*/
ANTHROPIC_CLAUDE_CODE("anthropic-claude-code", "ClaudeCodeChatModel"),
GEMINI_NATIVE("gemini-native", "GeminiChatModel"),

View File

@ -0,0 +1,26 @@
package vip.mate.llm.routing;
import java.util.List;
import java.util.Set;
/**
* Read access to an agent's skill / provider bindings, as needed by
* {@link ProviderRouter} for capability-aware routing.
*
* <p>Declared in the {@code llm} layer so the routing code depends only on
* this abstraction. The {@code agent} layer supplies the implementation,
* keeping the dependency direction {@code agent llm}.
*/
public interface AgentBindingResolver {
/**
* Skill ids bound to the agent, or {@code null} when the agent has no
* explicit bindings (meaning "use the global default").
*/
Set<Long> getBoundSkillIds(Long agentId);
/**
* Provider ids the agent prefers, in priority order; empty when none.
*/
List<String> getPreferredProviderIds(Long agentId);
}

View File

@ -3,7 +3,6 @@ package vip.mate.llm.routing;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.llm.service.ModelCapabilityService.Modality;
@ -20,27 +19,22 @@ import java.util.List;
import java.util.Set;
/**
* RFC-090 §9.2 调整 C diagnostics-first ProviderRouter.
* Capability-aware provider routing.
*
* <p>This first iteration does not yet rewrite the fallback chain order
* (the existing {@code AgentBindingService.getPreferredProviderIds} +
* {@link vip.mate.agent.AgentGraphBuilder#buildFallbackChain} flow is
* already in place). Instead it:
* <p>Given an agent's bound skills, aggregates the {@code requires-model}
* capabilities they declare and uses that to:
* <ul>
* <li>{@link #diagnosePrimary} WARN when the chosen primary model is
* missing a capability the bound skills require;</li>
* <li>{@link #reorderForCapabilities} lift providers that satisfy the
* required modalities to the head of the fallback chain;</li>
* <li>{@link #selectPrimary} pick a primary model that satisfies the
* required modalities, falling back to the global default.</li>
* </ul>
*
* <ol>
* <li>Aggregates {@code requires-model} from the agent's bound skills'
* manifests.</li>
* <li>Compares the union against the primary model's resolved
* capability set ({@link ModelCapabilityService#resolve}).</li>
* <li>Logs a clear WARN if a capability is missing surfacing the
* same gap RFC-085's "ready" badge would render in UI.</li>
* </ol>
*
* <p>Promoting this to actual chain re-ordering (i.e. "prefer providers
* that satisfy modelNeeds") is straightforward once we have the data
* for it: add a phase between {@code reorderByPreferences} and the
* model build loop. That phase is intentionally not in this commit so
* we can ship the diagnostics path independently and watch it in dev.
* <p>Binding data is read through {@link AgentBindingResolver}, an
* abstraction declared in this package so the routing layer never depends
* on the agent layer directly.
*/
@Slf4j
@Service
@ -48,7 +42,7 @@ import java.util.Set;
public class ProviderRouter {
private final SkillRuntimeService skillRuntimeService;
private final AgentBindingService bindingService;
private final AgentBindingResolver bindingService;
private final ModelCapabilityService capabilityService;
private final ModelConfigService modelConfigService;
@ -134,7 +128,7 @@ public class ProviderRouter {
}
}
// ==================== chain reorder (RFC-090 §9.2 调整 C) ====================
// ==================== chain reorder ====================
/**
* Re-rank an already preference-ordered provider list so providers
@ -142,10 +136,9 @@ public class ProviderRouter {
* float to the head. Stable order otherwise providers that don't
* satisfy keep their existing relative order.
*
* <p>Called by {@link vip.mate.agent.AgentGraphBuilder#buildFallbackChain}
* after the user-preferences reorder. Only acts when bound skills
* actually declared {@code requires-model}; otherwise returns the
* input untouched.
* <p>Called when building the fallback chain, after the user-preferences
* reorder. Only acts when bound skills actually declared
* {@code requires-model}; otherwise returns the input untouched.
*/
public List<ModelProviderEntity> reorderForCapabilities(Long agentId,
List<ModelProviderEntity> ordered) {

View File

@ -1,71 +0,0 @@
package vip.mate.agent.chatmodel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-001 (Claude 4.7 contract): {@link AgentAnthropicChatModelBuilder#isClaude47}
* must correctly classify the model variants we'll see in production.
*
* <p>Reference: hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}.
* Claude 4.7 forbids temperature / top_p / top_k entirely the builder relies
* on this detector to skip those fields rather than letting Anthropic 400.
*/
class AgentAnthropicChatModelBuilderClaude47Test {
@Test
@DisplayName("isClaude47 detects hyphenated direct-API model names")
void detect_hyphenated() {
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-haiku-4-7"));
}
@Test
@DisplayName("isClaude47 detects dotted variants (e.g. OpenRouter / mixed dialects)")
void detect_dotted() {
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4.7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude.sonnet.4.7"));
}
@Test
@DisplayName("isClaude47 detects OpenRouter-style prefixed model ids")
void detect_openrouterPrefix() {
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4-7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-sonnet-4-7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4.7"));
}
@Test
@DisplayName("isClaude47 ignores 4.5 / 4.6 / 4.0 / 3.x and unrelated names")
void detect_negatives() {
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-6"));
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-5"));
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet"),
"3.7 must not match 4.7");
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-5-sonnet"));
// The "claude" prefix guard prevents non-Anthropic models from spuriously
// matching even if they contain "4-7" / "4.7" substrings.
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("gpt-4-7"),
"Non-Claude models must NOT match — claude prefix guard active");
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("nemotron-4-7-instruct"));
}
@Test
@DisplayName("isClaude47 null-safe")
void detect_nullSafe() {
assertFalse(AgentAnthropicChatModelBuilder.isClaude47(null));
assertFalse(AgentAnthropicChatModelBuilder.isClaude47(""));
}
@Test
@DisplayName("Note: claude-3-7-sonnet correctly distinguished from claude-4-7-*")
void detect_3_7_vs_4_7() {
// Both contain "-7" but only the second contains "4-7" as a substring.
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet-20250219"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7-20260415"),
"Date-stamped 4-7 variants must still match");
}
}

View File

@ -0,0 +1,70 @@
package vip.mate.llm.chatmodel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link AnthropicChatModelBuilder#isClaude47} must correctly classify the
* Claude 4.7 model variants we'll see in production.
*
* <p>Claude 4.7 forbids temperature / top_p / top_k entirely the builder
* relies on this detector to skip those fields rather than letting Anthropic 400.
*/
class AnthropicChatModelBuilderClaude47Test {
@Test
@DisplayName("isClaude47 detects hyphenated direct-API model names")
void detect_hyphenated() {
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-opus-4-7"));
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-sonnet-4-7"));
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-haiku-4-7"));
}
@Test
@DisplayName("isClaude47 detects dotted variants (e.g. OpenRouter / mixed dialects)")
void detect_dotted() {
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-opus-4.7"));
assertTrue(AnthropicChatModelBuilder.isClaude47("claude.sonnet.4.7"));
}
@Test
@DisplayName("isClaude47 detects OpenRouter-style prefixed model ids")
void detect_openrouterPrefix() {
assertTrue(AnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4-7"));
assertTrue(AnthropicChatModelBuilder.isClaude47("anthropic/claude-sonnet-4-7"));
assertTrue(AnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4.7"));
}
@Test
@DisplayName("isClaude47 ignores 4.5 / 4.6 / 4.0 / 3.x and unrelated names")
void detect_negatives() {
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-opus-4-6"));
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-sonnet-4-5"));
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet"),
"3.7 must not match 4.7");
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-3-5-sonnet"));
// The "claude" prefix guard prevents non-Anthropic models from spuriously
// matching even if they contain "4-7" / "4.7" substrings.
assertFalse(AnthropicChatModelBuilder.isClaude47("gpt-4-7"),
"Non-Claude models must NOT match — claude prefix guard active");
assertFalse(AnthropicChatModelBuilder.isClaude47("nemotron-4-7-instruct"));
}
@Test
@DisplayName("isClaude47 null-safe")
void detect_nullSafe() {
assertFalse(AnthropicChatModelBuilder.isClaude47(null));
assertFalse(AnthropicChatModelBuilder.isClaude47(""));
}
@Test
@DisplayName("Note: claude-3-7-sonnet correctly distinguished from claude-4-7-*")
void detect_3_7_vs_4_7() {
// Both contain "-7" but only the second contains "4-7" as a substring.
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet-20250219"));
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-opus-4-7-20260415"),
"Date-stamped 4-7 variants must still match");
}
}

View File

@ -1,4 +1,4 @@
package vip.mate.agent;
package vip.mate.llm.chatmodel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@ -16,9 +16,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* RFC-049 PR-2: {@link AssistantThinkingRelay} RelayEntry carries both
* per-assistant thinking and the caller's original {@code user} field, so the
* consumer can restore it when rebuilding the outbound request.
* {@link AssistantThinkingRelay} RelayEntry carries both per-assistant thinking
* and the caller's original {@code user} field, so the consumer can restore it
* when rebuilding the outbound request.
*/
class AssistantThinkingRelayTest {

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.BeforeEach;
@ -38,17 +38,17 @@ import static org.mockito.Mockito.when;
* tests can exercise the full assembly path without mocking the API client.
*/
@ExtendWith(MockitoExtension.class)
class AgentClaudeCodeChatModelBuilderTest {
class ClaudeCodeChatModelBuilderTest {
@Mock
private AgentAnthropicChatModelBuilder anthropicBuilder;
private AnthropicChatModelBuilder anthropicBuilder;
@Mock
private ClaudeCodeOAuthService oauthService;
private ClaudeCodeApiHeaders apiHeaders;
private AgentClaudeCodeChatModelBuilder builder;
private ClaudeCodeChatModelBuilder builder;
@BeforeEach
void setUp() {
@ -60,7 +60,7 @@ class AgentClaudeCodeChatModelBuilderTest {
};
apiHeaders = new ClaudeCodeApiHeaders(detector);
builder = new AgentClaudeCodeChatModelBuilder(
builder = new ClaudeCodeChatModelBuilder(
anthropicBuilder,
oauthService,
apiHeaders,
@ -82,7 +82,7 @@ class AgentClaudeCodeChatModelBuilderTest {
// Sanity check: the NoopApiKey path passes Spring AI's notNull assertion
// and the OAuth headers attach without throwing. If this test ever
// fails, the most likely cause is a Spring AI upgrade tightening the
// ApiKey contract see AgentClaudeCodeChatModelBuilder javadoc.
// ApiKey contract see ClaudeCodeChatModelBuilder javadoc.
AnthropicApi api = builder.buildOauthAnthropicApi("sk-ant-oat01-test-token");
assertNotNull(api);
}

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

View File

@ -1,4 +1,4 @@
package vip.mate.agent.chatmodel;
package vip.mate.llm.chatmodel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@ -12,7 +12,6 @@ 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.agent.ThinkingLevelHolder;
import java.util.HashMap;
import java.util.List;
@ -109,8 +108,8 @@ class DeepSeekV4ThinkingDecoratorTest {
@Test
@DisplayName("mapEffort: low/medium/high passthrough; max collapses to high; unknown → medium")
void mapEffort_levels() {
// openclaw resolveDeepSeekV4ReasoningEffort folds "max" into "high"
// because DeepSeek doesn't expose a max tier. Pin both ends of the rule.
// "max" folds into "high" because DeepSeek doesn't expose a max tier.
// Pin both ends of the rule.
assertEquals("low", DeepSeekV4ThinkingDecorator.mapEffort("low"));
assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort("medium"));
assertEquals("high", DeepSeekV4ThinkingDecorator.mapEffort("high"));

View File

@ -1,4 +1,4 @@
package vip.mate.agent;
package vip.mate.llm.chatmodel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@ -21,8 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
/**
* RFC-049 PR-2 consumer-side tests for
* {@link AgentGraphBuilder#patchReasoningContent(ChatCompletionRequest, ModelProviderEntity)}.
* Consumer-side tests for
* {@link OpenAiRequestRewriter#patchReasoningContent(ChatCompletionRequest, ModelProviderEntity)}.
*
* <p>Covers four orthogonal dimensions:
* <ul>
@ -101,7 +101,7 @@ class PatchReasoningContentTest {
), "caller-user-1");
// model is "test-model" which maps to STANDARD family requiresReasoningContentPatch returns false
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
assertSame(req, out, "no thinking signal → no rebuild");
assertEquals("caller-user-1", out.user(), "user field untouched");
}
@ -117,7 +117,7 @@ class PatchReasoningContentTest {
assistantPlain("hi")
), fakeToken);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("openai"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("openai"));
assertNotSame(req, out, "rebuild expected to strip leaked token");
assertNull(out.user(), "leaked token must be sanitized to null");
}
@ -136,7 +136,7 @@ class PatchReasoningContentTest {
assistantToolCall("a1", null) // i=2, position 1 in thinkings "in-turn-think"
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
assertEquals("original-caller-42", out.user(), "sanitizedUser must equal entry.originalUser()");
assertEquals("in-turn-think", out.messages().get(2).reasoningContent(),
@ -167,7 +167,7 @@ class PatchReasoningContentTest {
assistantToolCall("a2", null) // i=4, in-turn (4 > 3)
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
assertEquals(" ", out.messages().get(2).reasoningContent(),
"cross-turn A1 gets ' ' fallback so DeepSeek thinking-mode validation passes");
@ -193,7 +193,7 @@ class PatchReasoningContentTest {
assistantToolCall("a4", null) // i=5 in-turn
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
// DEEPSEEK patchCrossTurn=true: cross-turn now also gets ' ' fallback.
// Iterator alignment is preserved: A1/A2 consume the empty entries '',
@ -220,7 +220,7 @@ class PatchReasoningContentTest {
assistantToolCall("a1", null) // in-turn
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"DeepSeek: ' ' fallback restores forward progress when relay has no real value");
@ -243,7 +243,7 @@ class PatchReasoningContentTest {
null, null, null, null, null, null
);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("kimi-cn"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"Kimi tolerates ' ' — preserve legacy behavior");
@ -266,7 +266,7 @@ class PatchReasoningContentTest {
null, null, null, null, null, null
);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("custom-gateway"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("custom-gateway"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"DEFAULT keeps legacy ' ' for unrecognized providers — avoid regressing self-hosted backends");
@ -285,7 +285,7 @@ class PatchReasoningContentTest {
assistantPlain("plain answer") // no tool_calls
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
assertEquals("thinking-for-plain", out.messages().get(1).reasoningContent(),
"DeepSeek contract requires reasoning_content even on non-tool_call assistants when in thinking mode");
@ -307,7 +307,7 @@ class PatchReasoningContentTest {
null, null, null, null, null, null
);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("kimi-cn"));
assertNull(out.messages().get(1).reasoningContent(),
"Kimi only patches tool_call assistants; plain assistants are untouched");
@ -326,7 +326,7 @@ class PatchReasoningContentTest {
assistantToolCall("a1", "pre-existing-real-thinking") // already has a value
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
assertEquals("pre-existing-real-thinking", out.messages().get(1).reasoningContent(),
"non-blank existing reasoning_content must not be overwritten by relay");
@ -338,7 +338,7 @@ class PatchReasoningContentTest {
@DisplayName("Empty messages list: no-op, returns same instance")
void emptyMessages_noop() {
ChatCompletionRequest req = request(List.of(), null);
assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")));
assertSame(req, OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")));
}
@Test
@ -349,7 +349,7 @@ class PatchReasoningContentTest {
null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null
);
assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")));
assertSame(req, OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")));
}
// ---------- Fewer relay entries than assistants: defensive policy fallback ----------
@ -368,7 +368,7 @@ class PatchReasoningContentTest {
assistantToolCall("a2", null)
)), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
assertEquals("real-1", out.messages().get(1).reasoningContent());
assertEquals(" ", out.messages().get(2).reasoningContent(),
@ -394,7 +394,7 @@ class PatchReasoningContentTest {
assistantToolCall("a2", null) // i=3, in-turn (3 > 2)
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("kimi-cn"));
assertNull(out.messages().get(1).reasoningContent(),
"KIMI does not patch cross-turn — thinking resets across user turns");
@ -420,7 +420,7 @@ class PatchReasoningContentTest {
new ChatCompletionMessage("plain a2", Role.ASSISTANT)
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"DEEPSEEK plain prior-turn assistant gets ' ' so request validates");

View File

@ -1,4 +1,4 @@
package vip.mate.agent;
package vip.mate.llm.chatmodel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -14,8 +14,8 @@ import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* RFC-049 PR-1.3 verification covers §5.2 Case E3.1 / E3.2 / E3.3 plus the
* whitelist positive path.
* Verification of {@link OpenAiRequestRewriter#sanitizeReasoningEffortForProvider}
* and {@link OpenAiRequestRewriter#isReasoningEffortWhitelistedProvider}.
*
* <p>The sanitizer is provider-first with default-deny: only providerId in
* {@code {openai, azure-openai}} is allowed to carry {@code reasoning_effort}.
@ -77,41 +77,41 @@ class ReasoningEffortSanitizerTest {
@Test
@DisplayName("Whitelist: openai is allowed")
void whitelist_openai() {
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("openai")));
assertTrue(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("openai")));
}
@Test
@DisplayName("Whitelist: azure-openai is allowed")
void whitelist_azureOpenai() {
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("azure-openai")));
assertTrue(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("azure-openai")));
}
@Test
@DisplayName("Whitelist: case-insensitive (Azure-OpenAI)")
void whitelist_caseInsensitive() {
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("Azure-OpenAI")));
assertTrue(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("Azure-OpenAI")));
}
@Test
@DisplayName("Whitelist: deepseek is denied")
void denylist_deepseek() {
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("deepseek")));
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("deepseek")));
}
@Test
@DisplayName("Whitelist: kimi family denied")
void denylist_kimi() {
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-cn")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-intl")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-code")));
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("kimi-cn")));
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("kimi-intl")));
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("kimi-code")));
}
@Test
@DisplayName("Whitelist: dashscope / ollama / anthropic denied")
void denylist_misc() {
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("dashscope")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("ollama")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("anthropic")));
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("dashscope")));
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("ollama")));
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("anthropic")));
}
@Test
@ -119,19 +119,19 @@ class ReasoningEffortSanitizerTest {
void denylist_unknownProvider() {
// This is the critical regression guard: if anyone re-adds a default-allow
// branch to isReasoningEffortWhitelistedProvider, this case fails first.
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(
provider("my-custom-openai-compat-gateway")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(
provider("openrouter")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(
provider("together")));
}
@Test
@DisplayName("Whitelist: null provider / null providerId denied")
void denylist_nulls() {
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(null));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(new ModelProviderEntity()));
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(null));
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(new ModelProviderEntity()));
}
// ---------- sanitizeReasoningEffortForProvider ----------
@ -140,7 +140,7 @@ class ReasoningEffortSanitizerTest {
@DisplayName("Sanitize no-op: request has no reasoning_effort")
void sanitize_noop_noReasoningEffort() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", null);
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
assertSame(req, out, "should return same instance when reasoning_effort is already null");
}
@ -149,7 +149,7 @@ class ReasoningEffortSanitizerTest {
void sanitize_failover_deepseek_strips() {
// Simulate failover: OpenAiChatOptions.model still leaked as "gpt-5" on the deepseek request.
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
assertNull(out.reasoningEffort(), "deepseek is not on the whitelist — strip regardless of model name");
// Other fields preserved
assertEquals("gpt-5", out.model());
@ -160,7 +160,7 @@ class ReasoningEffortSanitizerTest {
void sanitize_failover_otherDenied_strips() {
for (String pid : List.of("kimi-cn", "kimi-intl", "kimi-code", "dashscope", "ollama", "anthropic")) {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "medium");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider(pid));
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider(pid));
assertNull(out.reasoningEffort(), "provider=" + pid + " must strip");
}
}
@ -169,7 +169,7 @@ class ReasoningEffortSanitizerTest {
@DisplayName("§5.2 Case E3.3: unknown provider strips (default-deny regression guard)")
void sanitize_unknownProvider_strips() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(
req, provider("my-custom-openai-compat-gateway"));
assertNull(out.reasoningEffort(),
"unknown provider must strip (default-deny) — if this fails, someone re-added default-allow");
@ -179,7 +179,7 @@ class ReasoningEffortSanitizerTest {
@DisplayName("Whitelist + supporting model: keep reasoning_effort (gpt-5 on openai)")
void sanitize_whitelisted_supportingModel_keeps() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai"));
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("openai"));
assertSame(req, out, "gpt-5 on openai should pass through unchanged");
assertEquals("high", out.reasoningEffort());
}
@ -189,7 +189,7 @@ class ReasoningEffortSanitizerTest {
void sanitize_whitelisted_nonSupportingModel_strips() {
// gpt-4 is NOT OPENAI_REASONING family reasoning_effort is not applicable there.
OpenAiApi.ChatCompletionRequest req = request("gpt-4", "medium");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai"));
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("openai"));
assertNull(out.reasoningEffort(),
"gpt-4 is whitelisted-provider but non-supporting-family — family gate should strip");
}
@ -198,7 +198,7 @@ class ReasoningEffortSanitizerTest {
@DisplayName("Azure OpenAI with supporting model: keep reasoning_effort")
void sanitize_azureOpenai_supporting_keeps() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "low");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("azure-openai"));
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("azure-openai"));
assertEquals("low", out.reasoningEffort());
}
@ -206,7 +206,7 @@ class ReasoningEffortSanitizerTest {
@DisplayName("Null provider: strip (defensive)")
void sanitize_nullProvider_strips() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, null);
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, null);
assertNull(out.reasoningEffort());
}
}

View File

@ -67,7 +67,7 @@ class ModelConfigServiceDefaultModelTest {
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true);
ModelConfigEntity result = service.getDefaultModel();
@ -89,8 +89,8 @@ class ModelConfigServiceDefaultModelTest {
// First selectOne the is_default=true model
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
// dashscope is NOT configured, zhipu IS
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(false);
when(modelProviderService.isProviderConfigured("zhipu")).thenReturn(true);
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(false);
when(modelProviderService.isProviderEnabledAndConfigured("zhipu")).thenReturn(true);
// Full-scan returns both; zhipu comes second but dashscope is skipped
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(dashscopeDefault, zhipuModel));
@ -110,7 +110,7 @@ class ModelConfigServiceDefaultModelTest {
ModelConfigEntity zhipuModel = chatModel("zhipu", "glm-4", false);
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
when(modelProviderService.isProviderConfigured(any())).thenReturn(false);
when(modelProviderService.isProviderEnabledAndConfigured(any())).thenReturn(false);
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(dashscopeDefault, zhipuModel));
@ -140,7 +140,7 @@ class ModelConfigServiceDefaultModelTest {
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
// With null providerService, isProviderConfigured returns true (lenient bootstrap)
// With null providerService, isProviderEnabledAndConfigured returns true (lenient bootstrap)
ModelConfigEntity result = service.getDefaultModel();
assertEquals("dashscope", result.getProvider());
}

View File

@ -73,7 +73,7 @@ class ModelConfigServiceResolveModelTest {
// resolveModel skips its own selectOne for null/blank input, then calls getDefaultModel(),
// which itself runs one selectOne lookup for the default flag.
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel);
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true);
ModelConfigEntity result = service.resolveModel(null);
@ -88,7 +88,7 @@ class ModelConfigServiceResolveModelTest {
void blankNameFallsBack() {
ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true);
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel);
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true);
ModelConfigEntity result = service.resolveModel(" ");
@ -113,7 +113,7 @@ class ModelConfigServiceResolveModelTest {
assertEquals("claude-3-5-sonnet", result.getModelName());
// Exactly one lookup getDefaultModel must NOT be called.
verify(modelConfigMapper, times(1)).selectOne(any());
verify(modelProviderService, never()).isProviderConfigured(any());
verify(modelProviderService, never()).isProviderEnabledAndConfigured(any());
}
// Unmatched fall back to default
@ -126,7 +126,7 @@ class ModelConfigServiceResolveModelTest {
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(null) // 1st: name lookup misses
.thenReturn(defaultModel); // 2nd: default flag lookup
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true);
ModelConfigEntity result = service.resolveModel("ghost-model");

View File

@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.auth.service.AuthService;
import vip.mate.memory.model.DreamReportEntity;
import vip.mate.memory.model.MemoryRecallEntity;
import vip.mate.memory.repository.DreamReportMapper;
@ -36,13 +37,14 @@ class HilEditValidationTest {
@Mock private MorningCardService morningCardService;
@Mock private MemoryHilService hilService;
@Mock private DreamEventBroadcaster eventBroadcaster;
@Mock private AuthService authService;
private DreamController controller;
@BeforeEach
void setUp() {
controller = new DreamController(dreamReportMapper, recallMapper,
morningCardService, hilService, eventBroadcaster);
morningCardService, hilService, eventBroadcaster, authService);
}
@Test