From a88edbdd07cca927b583fcbecc57c6acb0b095dc Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 18 May 2026 07:47:49 +0800 Subject: [PATCH] refactor(llm): decouple model construction from the agent graph layer (#147) --- .../java/vip/mate/MateClawApplication.java | 2 +- .../vip/mate/agent/AgentGraphBuilder.java | 1270 +---------------- .../java/vip/mate/agent/AgentService.java | 1 + .../vip/mate/agent/ThinkingLevelHolder.java | 33 - .../binding/service/AgentBindingService.java | 5 +- ...AgentOpenAiCompatibleChatModelBuilder.java | 66 - .../mate/agent/context/ChatOriginHolder.java | 2 +- .../agent/graph/NodeStreamingChatHelper.java | 4 +- .../agent/graph/StateGraphReActAgent.java | 2 +- .../mate/agent/graph/node/ReasoningNode.java | 2 +- .../oauth/ClaudeCodeOAuthService.java | 2 +- .../chatmodel/AnthropicChatModelBuilder.java} | 36 +- .../chatmodel}/AssistantThinkingRelay.java | 6 +- .../ClaudeCodeChatModelBuilder.java} | 22 +- .../ClaudeCodeIdentityChatModelDecorator.java | 10 +- .../ClaudeCodeSystemArrayExchangeFilter.java | 2 +- .../ClaudeCodeSystemArrayInterceptor.java | 2 +- .../chatmodel/DashScopeChatModelBuilder.java} | 11 +- .../DeepSeekV4ThinkingDecorator.java | 19 +- .../vip/mate/llm/chatmodel/HttpTimeouts.java | 19 +- .../OpenAiCompatibleChatModelBuilder.java | 472 ++++++ .../llm/chatmodel/OpenAiRequestRewriter.java | 736 ++++++++++ .../llm/chatmodel/ProviderGenerateKwargs.java | 89 ++ .../RateLimitDiagnosticExchangeFilter.java | 2 +- .../RateLimitDiagnosticInterceptor.java | 2 +- .../chatmodel/ReasoningEffortResolver.java | 63 + .../llm/chatmodel/ThinkingLevelHolder.java | 36 + .../vip/mate/llm/model/ModelConfigEntity.java | 2 +- .../java/vip/mate/llm/model/ModelFamily.java | 2 +- .../vip/mate/llm/model/ModelProtocol.java | 2 +- .../llm/routing/AgentBindingResolver.java | 26 + .../vip/mate/llm/routing/ProviderRouter.java | 45 +- ...AnthropicChatModelBuilderClaude47Test.java | 71 - ...AnthropicChatModelBuilderClaude47Test.java | 70 + .../AssistantThinkingRelayTest.java | 8 +- .../ClaudeCodeChatModelBuilderTest.java} | 12 +- ...udeCodeIdentityChatModelDecoratorTest.java | 2 +- .../DeepSeekV4ThinkingDecoratorTest.java | 7 +- .../chatmodel}/PatchReasoningContentTest.java | 38 +- .../ReasoningEffortSanitizerTest.java | 52 +- .../ModelConfigServiceDefaultModelTest.java | 10 +- .../ModelConfigServiceResolveModelTest.java | 8 +- .../controller/HilEditValidationTest.java | 4 +- 43 files changed, 1662 insertions(+), 1613 deletions(-) delete mode 100644 mateclaw-server/src/main/java/vip/mate/agent/ThinkingLevelHolder.java delete mode 100644 mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java rename mateclaw-server/src/main/java/vip/mate/{agent/chatmodel/AgentAnthropicChatModelBuilder.java => llm/chatmodel/AnthropicChatModelBuilder.java} (89%) rename mateclaw-server/src/main/java/vip/mate/{agent => llm/chatmodel}/AssistantThinkingRelay.java (96%) rename mateclaw-server/src/main/java/vip/mate/{agent/chatmodel/AgentClaudeCodeChatModelBuilder.java => llm/chatmodel/ClaudeCodeChatModelBuilder.java} (91%) rename mateclaw-server/src/main/java/vip/mate/{agent => llm}/chatmodel/ClaudeCodeIdentityChatModelDecorator.java (97%) rename mateclaw-server/src/main/java/vip/mate/{agent => llm}/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java (98%) rename mateclaw-server/src/main/java/vip/mate/{agent => llm}/chatmodel/ClaudeCodeSystemArrayInterceptor.java (99%) rename mateclaw-server/src/main/java/vip/mate/{agent/chatmodel/AgentDashScopeChatModelBuilder.java => llm/chatmodel/DashScopeChatModelBuilder.java} (96%) rename mateclaw-server/src/main/java/vip/mate/{agent => llm}/chatmodel/DeepSeekV4ThinkingDecorator.java (91%) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ProviderGenerateKwargs.java rename mateclaw-server/src/main/java/vip/mate/{agent => llm}/chatmodel/RateLimitDiagnosticExchangeFilter.java (98%) rename mateclaw-server/src/main/java/vip/mate/{agent => llm}/chatmodel/RateLimitDiagnosticInterceptor.java (99%) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningEffortResolver.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ThinkingLevelHolder.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java delete mode 100644 mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderClaude47Test.java rename mateclaw-server/src/test/java/vip/mate/{agent => llm/chatmodel}/AssistantThinkingRelayTest.java (95%) rename mateclaw-server/src/test/java/vip/mate/{agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java => llm/chatmodel/ClaudeCodeChatModelBuilderTest.java} (94%) rename mateclaw-server/src/test/java/vip/mate/{agent => llm}/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java (99%) rename mateclaw-server/src/test/java/vip/mate/{agent => llm}/chatmodel/DeepSeekV4ThinkingDecoratorTest.java (97%) rename mateclaw-server/src/test/java/vip/mate/{agent => llm/chatmodel}/PatchReasoningContentTest.java (90%) rename mateclaw-server/src/test/java/vip/mate/{agent => llm/chatmodel}/ReasoningEffortSanitizerTest.java (73%) diff --git a/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java b/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java index c0eaf1c5..3e33d262 100644 --- a/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java +++ b/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java @@ -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, diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 8a3ddd12..eb252a7e 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -1,6 +1,6 @@ package vip.mate.agent; -// PR-0b: DashScope imports moved with the construction code into AgentDashScopeChatModelBuilder. +// PR-0b: DashScope imports moved with the construction code into DashScopeChatModelBuilder. import com.alibaba.cloud.ai.graph.CompiledGraph; import com.alibaba.cloud.ai.graph.CompileConfig; import com.alibaba.cloud.ai.graph.KeyStrategy; @@ -8,35 +8,12 @@ import com.alibaba.cloud.ai.graph.KeyStrategyFactory; import com.alibaba.cloud.ai.graph.StateGraph; import com.alibaba.cloud.ai.graph.action.AsyncEdgeAction; import com.alibaba.cloud.ai.graph.action.AsyncNodeAction; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.micrometer.observation.ObservationRegistry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -// PR-0b: Anthropic imports moved with the construction code into AgentAnthropicChatModelBuilder. import org.springframework.ai.chat.client.ChatClient; 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.retry.support.RetryTemplate; import org.springframework.stereotype.Component; -import org.springframework.http.HttpHeaders; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.util.StringUtils; -import org.springframework.http.client.JdkClientHttpRequestFactory; -import org.springframework.web.client.RestClient; -import java.net.http.HttpClient; -import java.time.Duration; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; -import reactor.core.publisher.Flux; -import vip.mate.agent.ThinkingLevelHolder; import vip.mate.agent.graph.StateGraphReActAgent; import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.graph.executor.ToolExecutionExecutor; @@ -55,6 +32,8 @@ import vip.mate.agent.binding.service.AgentBindingService; import vip.mate.agent.model.AgentEntity; import vip.mate.config.GraphObservationProperties; import vip.mate.exception.MateClawException; +import vip.mate.llm.chatmodel.OpenAiCompatibleChatModelBuilder; +import vip.mate.llm.chatmodel.ReasoningEffortResolver; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelFamily; import vip.mate.llm.model.ModelProtocol; @@ -109,12 +88,8 @@ public class AgentGraphBuilder { private final ApprovalWorkflowService approvalService; private final ChatStreamTracker streamTracker; private final SystemSettingService systemSettingService; - // PR-0b: dashScopeChatModel + dashScopeConnectionProperties live on AgentDashScopeChatModelBuilder now. + // PR-0b: dashScopeChatModel + dashScopeConnectionProperties live on DashScopeChatModelBuilder now. private final RetryTemplate retryTemplate; - private final ObjectProvider observationRegistryProvider; - private final ObjectProvider restClientBuilderProvider; - private final ObjectProvider webClientBuilderProvider; - private final ObjectMapper objectMapper; private final GraphObservationProperties graphObservationProperties; private final vip.mate.config.ToolTimeoutProperties toolTimeoutProperties; private final MemoryManager memoryManager; @@ -132,8 +107,8 @@ public class AgentGraphBuilder { private final vip.mate.llm.chatmodel.ProviderChatModelFactory chatModelFactory; private final vip.mate.llm.failover.AvailableProviderPool providerPool; private final vip.mate.tool.document.GeneratedFileCache generatedFileCache; - /** PR-0b: DashScope-specific construction lives here now; we only call into it for the search-on log. */ - private final vip.mate.agent.chatmodel.AgentDashScopeChatModelBuilder dashScopeBuilder; + /** DashScope-specific construction lives here; only called for the built-in-search log. */ + private final vip.mate.llm.chatmodel.DashScopeChatModelBuilder dashScopeBuilder; private final vip.mate.llm.routing.MultimodalRouter multimodalRouter; private final vip.mate.llm.routing.MediaCaptionService mediaCaptionService; @@ -235,7 +210,8 @@ public class AgentGraphBuilder { Map providerKwargs = modelProviderService.readProviderGenerateKwargs(provider); if (protocol == ModelProtocol.DASHSCOPE_NATIVE) { builtinSearchEnabled = dashScopeBuilder.isBuiltinSearchEnabled(runtimeModel, provider); - } else if (isKimiProvider(provider) && Boolean.TRUE.equals(providerKwargs.get("enableSearch"))) { + } else if (OpenAiCompatibleChatModelBuilder.isKimiProvider(provider) + && Boolean.TRUE.equals(providerKwargs.get("enableSearch"))) { builtinSearchEnabled = true; } if (builtinSearchEnabled) { @@ -973,7 +949,7 @@ public class AgentGraphBuilder { } // PR-0b: legacy single-fallback buildFallbackModel deleted (already @Deprecated, no callers). - // PR-0b: isDashScopeSearchEnabled moved to AgentDashScopeChatModelBuilder. + // PR-0b: isDashScopeSearchEnabled moved to DashScopeChatModelBuilder. // ==================== Prompt 构建 ==================== @@ -1123,1236 +1099,14 @@ public class AgentGraphBuilder { return basePrompt + skillEnhancement + toolGuidance + searchGuidance + wikiContext; } - // ==================== 模型选项构建 ==================== - - // PR-0b: buildDashScopeOptions moved to AgentDashScopeChatModelBuilder - - /** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0c (OpenAI). */ - public OpenAiChatOptions buildOpenAiOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) { - OpenAiChatOptions.Builder builder = OpenAiChatOptions.builder(); - Map kwargs = modelProviderService.readProviderGenerateKwargs(provider); - String modelName = runtimeModel.getModelName(); - ModelFamily family = ModelFamily.detect(modelName); - - if (StringUtils.hasText(modelName)) { - builder.model(modelName); - } - - // temperature:部分模型族强制 1.0 - Double temperature = resolveOpenAiTemperature(modelName, runtimeModel.getTemperature(), kwargs, family); - if (temperature != null) { - builder.temperature(temperature); - } - - // max_tokens / max_completion_tokens:按模型族路由 - if (family.suppressMaxTokens()) { - // OPENAI_REASONING 族:禁止 max_tokens,改用 max_completion_tokens - // fallback 优先级:kwargs.maxCompletionTokens > kwargs.maxTokens > config.maxTokens - Integer kwargsMaxTokens = resolveIntegerOption("maxTokens", runtimeModel.getMaxTokens(), kwargs); - Integer maxCompletionTokens = 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 { - // 其他模型族:正常使用 max_tokens - Integer maxTokens = resolveIntegerOption("maxTokens", runtimeModel.getMaxTokens(), kwargs); - if (maxTokens != null) { - builder.maxTokens(maxTokens); - } - // 仍允许通过 generateKwargs 手动指定 maxCompletionTokens - Integer maxCompletionTokens = resolveIntegerOption("maxCompletionTokens", null, kwargs); - if (maxCompletionTokens != null) { - builder.maxCompletionTokens(maxCompletionTokens); - } - } - - // top_p:部分模型族禁止发送 - Double topP = resolveOpenAiTopP(modelName, runtimeModel.getTopP(), kwargs, family); - if (topP != null) { - builder.topP(topP); - } - - // reasoning_effort:仅支持的模型族才注入 - String reasoningEffort = resolveReasoningEffort(modelName, kwargs, family); - if (StringUtils.hasText(reasoningEffort)) { - builder.reasoningEffort(reasoningEffort); - } - - // 内置搜索:模型级字段优先,provider generateKwargs 作为 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); - // 注意:不设置 parallelToolCalls — 设为 false 会导致无 tools 时 OpenAI 返回 400: - // "parallel_tool_calls is only allowed when 'tools' are specified" - // 保持 null 让 Spring AI 不序列化该字段,由各 Node 在有 tools 时自行控制。 - options.setStreamUsage(true); - return options; - } - - // ==================== OpenAI API 构建 ==================== - - /** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */ - public OpenAiApi buildOpenAiApi(ModelProviderEntity provider) { - return buildOpenAiApi(provider, null); - } - /** - * Overload that accepts a per-model read-timeout override (seconds). - * Threaded into both the sync RestClient and streaming WebClient so - * timeout behavior is consistent across blocking and streaming chat - * completions. Null falls back to the default 180s. - */ - public 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. Without this the chat path - // rejected providers that probe / discovery / connection-test all considered usable. - 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 kwargs = modelProviderService.readProviderGenerateKwargs(provider); - MultiValueMap 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 OpenAiApi 构造函数会先 set User-Agent 为 "spring-ai",再 addAll 我们的 headers, - // 导致自定义 User-Agent 被追加而非覆盖。因此对需要伪装客户端身份的 provider(如 kimi-code), - // 通过 RestClient/WebClient 拦截器在请求发出前强制覆盖 headers。 - Map 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 chatCompletionEntity( - OpenAiApi.ChatCompletionRequest chatRequest, - MultiValueMap additionalHttpHeader) { - chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider); - chatRequest = patchReasoningContent(chatRequest, provider); - chatRequest = stripReasoningEffortIfIncompatible(chatRequest); - chatRequest = stripAutoToolChoice(chatRequest); - chatRequest = patchVideoMediaContent(chatRequest); - if (kimiSearchEnabled) { - chatRequest = injectKimiWebSearch(chatRequest); - } - logOpenAiRequest(provider, chatRequest); - try { - return super.chatCompletionEntity(chatRequest, additionalHttpHeader); - } catch (WebClientResponseException e) { - logOpenAiError(provider, e); - throw e; - } - } - - @Override - public Flux chatCompletionStream( - OpenAiApi.ChatCompletionRequest chatRequest, - MultiValueMap additionalHttpHeader) { - chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider); - chatRequest = patchReasoningContent(chatRequest, provider); - chatRequest = stripReasoningEffortIfIncompatible(chatRequest); - chatRequest = stripAutoToolChoice(chatRequest); - chatRequest = patchVideoMediaContent(chatRequest); - if (kimiSearchEnabled) { - chatRequest = injectKimiWebSearch(chatRequest); - } - logOpenAiRequest(provider, chatRequest); - return super.chatCompletionStream(chatRequest, additionalHttpHeader) - .doOnError(error -> { - if (error instanceof WebClientResponseException e) { - logOpenAiError(provider, e); - } - }); - } - }; - } - - // ==================== DashScope API 构建 ==================== - - // PR-0b: buildDashScopeApi moved to AgentDashScopeChatModelBuilder - - // ==================== Anthropic API 构建 ==================== - - // PR-0b: buildAnthropicApi + buildAnthropicOptions moved to AgentAnthropicChatModelBuilder - - // ==================== 参数解析辅助方法 ==================== - - private Double resolveOpenAiTemperature(String modelName, Double configuredTemperature, - Map kwargs, ModelFamily family) { - Double overriddenTemperature = 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 kwargs, ModelFamily family) { - if (family.suppressTopP()) { - return null; - } - return resolveDoubleOption("topP", configuredTopP, kwargs); - } - - private boolean requiresFixedTemperatureOne(String modelName) { - return ModelFamily.detect(modelName).fixedTemperatureOne(); - } - - private String resolveReasoningEffort(String modelName, Map kwargs, ModelFamily family) { - // PR-1.1 (RFC-049 L1-A): Only families that actually accept reasoning_effort may receive - // it. Previously only the default-inject branch checked capability; the generateKwargs - // override branch did not, so a provider-level `reasoningEffort: "high"` would leak to - // deepseek-chat / kimi-k2 / deepseek-reasoner etc., triggering the incident documented - // in RFC-049 (DeepSeek "reasoning_content missing" 400). - if (!family.supportsReasoningEffort()) { - Object overridden = 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; - } - // generateKwargs 显式覆盖始终优先(仅在白名单族内) - Object value = findOptionValue(kwargs, "reasoningEffort"); - if (value instanceof String text && StringUtils.hasText(text)) { - return text.trim(); - } - // 仅支持 reasoning_effort 的模型族才自动注入默认值 - if (family.isThinking()) { - return "medium"; - } - return null; - } - - private boolean isThinkingModel(String modelName) { - return ModelFamily.detect(modelName).isThinking(); - } - - /** - * 从 ModelConfigEntity 中解析 reasoningEffort,用于传递给 StepExecutionNode / ReasoningNode。 - * 复用已有的 resolveReasoningEffort + isThinkingModel 逻辑。 + * Resolve the {@code reasoning_effort} to pass to the reasoning / + * step-execution nodes for the given model. */ private String resolveReasoningEffortForModel(ModelConfigEntity runtimeModel) { ModelProviderEntity provider = modelProviderService.getProviderConfig(runtimeModel.getProvider()); Map kwargs = modelProviderService.readProviderGenerateKwargs(provider); ModelFamily family = ModelFamily.detect(runtimeModel.getModelName()); - return resolveReasoningEffort(runtimeModel.getModelName(), kwargs, family); - } - - private Double resolveDoubleOption(String key, Double fallback, Map 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; - } - - private Integer resolveIntegerOption(String key, Integer fallback, Map 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; - } - - @SuppressWarnings("unchecked") - private Object findOptionValue(Map 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 Object findKwarg(Map 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) optionsMap).get(key); - } - return null; - } - - // ==================== URL 规范化 ==================== - - // PR-0b: normalizeDashScopeBaseUrl moved to AgentDashScopeChatModelBuilder - - 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; - } - - // ==================== Kimi 内置搜索 ==================== - - private static boolean isKimiProvider(ModelProviderEntity provider) { - if (provider == null) return false; - String id = provider.getProviderId(); - return "kimi-cn".equals(id) || "kimi-intl".equals(id); - } - - /** - * 为 Kimi 请求注入 $web_search builtin tool。 - * Kimi 的内置搜索通过 tools 数组中声明 {"type":"builtin_function","function":{"name":"$web_search"}} 实现。 - * 由于 Spring AI 的 FunctionTool.Type 只有 FUNCTION,无法直接构造 builtin_function 类型, - * 因此通过 extraBody 注入原始 JSON 结构覆盖 tools 字段(包含原有 tools + $web_search)。 - */ - private static OpenAiApi.ChatCompletionRequest injectKimiWebSearch(OpenAiApi.ChatCompletionRequest request) { - // 构造 $web_search entry 作为 Map - Map webSearchTool = Map.of( - "type", "builtin_function", - "function", Map.of("name", "$web_search") - ); - - // 将原有 tools 转为 List 并追加 $web_search - List> allTools = new ArrayList<>(); - if (request.tools() != null) { - for (OpenAiApi.FunctionTool tool : request.tools()) { - Map toolMap = new LinkedHashMap<>(); - toolMap.put("type", "function"); - if (tool.getFunction() != null) { - Map 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); - - // 通过 extraBody 注入 tools(覆盖原有 tools 字段),同时清空原 tools 避免重复序列化 - Map 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 — 清空,由 extraBody 接管 - request.toolChoice(), - request.parallelToolCalls(), - request.user(), - request.reasoningEffort(), - request.webSearchOptions(), - request.verbosity(), - request.promptCacheKey(), - request.safetyIdentifier(), - extraBody - ); - } - - // PR-0b: reflection helpers (readApiKey/BaseUrl/DashScopeApiFromDefaultChatModel) - // moved to AgentDashScopeChatModelBuilder - - // ==================== 日志辅助 ==================== - - private MultiValueMap buildOpenAiHeaders(Map kwargs) { - LinkedMultiValueMap 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; - } - - /** - * RFC-012 M1:给 LLM 调用走的 RestClient 显式配置超时,避免 socket 永久挂起等待。 - *

- * 使用 {@link JdkClientHttpRequestFactory}(基于 Java 11+ {@link HttpClient}),原因: - *

    - *
  • 原生支持 HTTP/2 / ALPN 协商(Kimi 等现代 LLM provider 默认 HTTP/2)
  • - *
  • 自动处理 {@code Content-Encoding: gzip} 解压({@code SimpleClientHttpRequestFactory} - * 基于旧的 {@code HttpURLConnection},不会自动解压,会把 gzip 流误标为 - * {@code application/octet-stream} 导致 RestClient 抛 "Error extracting response")
  • - *
  • 对 chunked transfer + 非标准 content-type 的回退处理符合现代 spec
  • - *
- *

- * connectTimeout=10s(任何 LLM 提供方都不该超过这个建立连接时间); - * readTimeout=180s(覆盖 nginx 60s 网关超时 + 留足真实长响应余量;超时后由上层 retry 接管)。 - */ - private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) { - return applyHttpTimeouts(builder, null); - } - - /** - * Overload that accepts a per-model read-timeout override (seconds). - * Null falls back to the default 180s. - */ - private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) { - HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT) - .version(HttpClient.Version.HTTP_1_1) - .build(); - JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient); - rf.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride)); - return builder.requestFactory(rf); - } - - /** - * Apply equivalent timeouts to the WebClient that backs OpenAI-compatible - * STREAMING calls (chat completions with {@code stream:true}). The - * RestClient version above only protects synchronous HTTP — without this, - * the streaming code path uses the default {@code WebClient} which has - * neither connect nor read timeout, so a stalled provider can hang the - * call forever (observed: a single volcengine-plan request held the agent - * thread for 9+ minutes with no error, until the user manually pressed - * Stop). That kept the failover chain idle because nothing threw. - *

- * Uses {@link JdkClientHttpConnector} with the same {@link HttpClient} we - * already use for the RestClient so the dependency surface stays clean - * (reactor-netty is not on this project's classpath — Spring's webflux - * starter is excluded by design). - */ - private WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) { - return applyHttpTimeoutsToWebClient(builder, null); - } - - /** - * Overload with the same per-model override semantics as - * {@link #applyHttpTimeouts(RestClient.Builder, Integer)}. - */ - 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(vip.mate.llm.chatmodel.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(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride)); - return builder.clientConnector(connector); - } - - /** - * 从 generateKwargs.headers 中提取需要强制覆盖的 headers。 - * 用于通过 RestClient/WebClient 拦截器绕过 Spring AI OpenAiApi 的默认 User-Agent。 - */ - private Map extractOverrideHeaders(Map kwargs) { - Map result = new java.util.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 java.util.regex.Pattern OPENAI_BASE_URL_VERSION_SUFFIX = - java.util.regex.Pattern.compile(".*/v\\d+$"); - - private String resolveOpenAiCompletionsPath(String baseUrl, Map 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; - } - - /** - * Consume the {@link AssistantThinkingRelay} entry and rebuild the outbound - * {@link OpenAiApi.ChatCompletionRequest} so that assistant tool-call / thinking - * messages carry the correct {@code reasoning_content}. - * - *

PR-2 (RFC-049 §2.3.2): This is the consumer side of the relay. - * {@code NodeStreamingChatHelper.doStreamCall} stashes per-assistant thinking - * keyed on a token embedded in {@code request.user()}. Here we: - *

    - *
  1. {@link AssistantThinkingRelay#take(String)} the entry and restore - * {@code request.user()} to {@code entry.originalUser()} (internal token - * never reaches the provider).
  2. - *
  3. Compute {@code lastUserIdx} (the boundary of the current user turn), - * symmetric to {@code stripThinkingFromPrompt}. Assistant messages at - * {@code i <= lastUserIdx} are prior-turn history: their - * {@code reasoning_content} must stay null. Only {@code i > lastUserIdx} - * messages are eligible for patching.
  4. - *
  5. Select a {@link FallbackPolicy} by {@code providerId}. When relay has - * a real value, we use it; when empty, the policy decides whether to - * inject {@code " "} (legacy tolerance: KIMI/OPENAI/DEFAULT) or leave - * {@code null} to surface an explicit provider error (DEEPSEEK).
  6. - *
- * - *

The relay iterator advances for every assistant message (including - * prior-turn ones) to stay positionally aligned with the producer's extraction - * in {@code NodeStreamingChatHelper.extractAssistantThinkings}. - */ - 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 — unchanged from the prior design except that 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 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 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 skip per stripThinkingFromPrompt's - // "thinking resets across user turns" rule. But DeepSeek (since - // 2026-04) requires reasoning_content even on prior-turn assistants - // and rejects 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 - // before sanitizeForLlm has a chance to filter the previous error. - 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() - ); - } - - /** - * PR-2 (RFC-049 §2.3.2): Provider-keyed policy for how {@code patchReasoningContent} - * should behave when the relay has no real thinking for an in-turn assistant message. - * - *

    - *
  • {@code emptyFallback}: value to inject when relay has no real value — - * {@code null} means leave {@code reasoning_content} null (DeepSeek); - * {@code " "} preserves Spring AI 1.1.4 legacy tolerance (Kimi/OpenAI/unknown).
  • - *
  • {@code warnOnMissingReal}: emit WARN when {@code emptyFallback==null} fires — - * only DeepSeek wants this, because there a missing value means we have a bug.
  • - *
  • {@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 (historical behavior).
  • - *
- * - * {@code DEFAULT} intentionally 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 — noop would regress - * those into new 400s. - */ - private enum FallbackPolicy { - // RFC-049 follow-up (2026-04-27): DEEPSEEK previously used (null, true, true) - // to "surface explicit provider error" when the producer-side relay had no - // captured reasoning_content. In practice this kept failing every multi-tool - // turn that crossed a summarizing boundary — the summarizer-produced - // assistant message has no reasoning_content by construction, the relay - // iterator has no entry for it, and DeepSeek returns 400 inside the same - // turn (not just multi-turn replay), aborting the whole graph at the - // reasoning step right after summarizing. Switching to the same " " - // tolerance KIMI/OPENAI use restores forward progress; the producer-side - // capture gap remains a real bug to fix in RFC-049 PR-3 but doesn't - // belong on the user-facing failure path. - // - // 2026-04-29 follow-up: DeepSeek tightened thinking-mode validation to - // require reasoning_content on EVERY assistant message in the request, - // including prior-turn history. We never persist reasoning_content to - // mate_message, so any conversation with >=1 prior turn fails with - // 400 "reasoning_content must be passed back" on the very first reasoning - // call. patchCrossTurn=true lets us extend the " " fallback to prior-turn - // assistants too, restoring forward progress for multi-turn IM chats. - // Real reasoning_content recovery (RFC-049 PR-3) is the proper long-term - // fix; this keeps users unblocked. - 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 {@link OpenAiApi.ChatCompletionRequest} with only the {@code user} field - * replaced. Used when {@code 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() - ); - } - - /** - * PR-1.3 (RFC-049 L1-C): Provider-first sanitization of {@code reasoning_effort}. - * - *

Authoritative judgement uses the target {@code provider.getProviderId()} as a - * whitelist (default-deny). Only OpenAI official providers are allowed to carry - * {@code reasoning_effort}; everything else — known non-supporters (DeepSeek / Kimi / - * DashScope / Ollama / …) and any unrecognized providerId (self-hosted gateways, - * OpenRouter / Together / aggregators) — is stripped unconditionally. - * - *

The reason we intentionally distrust {@code request.model()} here: MateClaw's - * failover chain (RFC-009) can reuse the same {@code Prompt} and {@code OpenAiChatOptions} - * across providers, and {@code OpenAiChatOptions.model} was set to the primary's model - * name (e.g. {@code gpt-5}). If the sanitizer only checked {@code ModelFamily.detect( - * request.model())}, a failover hop from GPT-5 → DeepSeek would see model name - * "gpt-5" → OPENAI_REASONING → {@code supportsReasoningEffort == true} and quietly - * forward the primary's {@code reasoning_effort} to DeepSeek, re-triggering the - * incident this RFC exists to fix. - * - *

Only when the provider is on the whitelist do we fall through to the - * {@link ModelFamily} check (e.g. within OpenAI, {@code gpt-4} still wouldn't support - * reasoning_effort). Outside the whitelist, no runtime check on model is trusted. - * - *

Adding a new provider to the whitelist must be an explicit PR with a sanitizer - * test — do not add a catch-all default-allow branch. - */ - 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 sanitizer 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) ? "" : p.getProviderId(); - } - - /** - * Rebuild a {@link OpenAiApi.ChatCompletionRequest} with a new {@code reasoningEffort} - * value (typically {@code null} to strip). Mirrors the record canonical-constructor - * pattern used by {@link #stripReasoningEffortIfIncompatible}. - */ - 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 兼容性:在 /v1/chat/completions 路径下,tools 与 reasoning_effort 不可同时存在。 - *

- * 当检测到 gpt-5* 模型同时携带 tools 和 reasoning_effort 时,自动移除 reasoning_effort 并记录警告日志。 - * 若需使用 reasoning_effort,应改用 /v1/responses 接口(通过 generateKwargs 的 completionsPath 配置)。 - */ - private 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 兼容] 模型 {} 在 chat/completions 下同时携带 tools 和 reasoning_effort," - + "自动移除 reasoning_effort 以避免 400 错误。" - + "如需 reasoning_effort,请将 completionsPath 配置为 /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 — 移除 - request.webSearchOptions(), - request.verbosity(), - request.promptCacheKey(), - request.safetyIdentifier(), - request.extraBody() - ); - } - - private static boolean requiresReasoningContentPatch(String modelName) { - ModelFamily family = ModelFamily.detect(modelName); - return family.isThinking(); - } - - /** - * Strip {@code tool_choice="auto"} from outbound chat-completion requests. - * - *

Per the OpenAI spec, omitting {@code tool_choice} when {@code tools} is non-empty - * is functionally equivalent to {@code "auto"} (the server defaults to auto-pick). - * Stripping the explicit literal {@code "auto"}: - *

    - *
  • does not change behavior on compliant servers (e.g. OpenAI, DashScope) — they - * still default to auto when tools are present
  • - *
  • 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, which is a common reason custom endpoints - * respond with a generic 400 / "body=None" Pydantic error
  • - *
- * - *

Explicit values other than {@code "auto"} ({@code "none"}, {@code "required"}, - * or a specific function descriptor) are passed through unchanged. - */ - private 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() - ); - } - - /** - * 将 Spring AI 错误地序列化为 image_url 的视频内容块转换为 video_url 格式。 - *

- * Spring AI 1.x 的 MediaContent 没有 video_url 类型,所有非 audio/pdf 的 Media - * 都被序列化为 image_url。智谱 GLM-5V 等模型要求视频使用 video_url 格式, - * 否则会报"图片输入格式/解析错误"。 - *

- * 此方法遍历 user 消息的 rawContent,将 data:video/* 前缀的 image_url 替换为 video_url。 - */ - @SuppressWarnings("unchecked") - private 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(Spring AI 内部用 LinkedHashMap 表示 content parts) - 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 patched = request.messages().stream().map(msg -> { - if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.USER || !(msg.rawContent() instanceof List parts)) { - return msg; - } - List newParts = new ArrayList<>(); - for (Object part : parts) { - String videoDataUrl = null; - - // 场景 1:MediaContent record(Spring AI 原生构建) - 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(); - } - // 场景 2:Map(Jackson 反序列化或 Spring AI 内部用 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) { - // 替换为 video_url 格式 - 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() - ); - } - - 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()); + return ReasoningEffortResolver.resolveReasoningEffort(runtimeModel.getModelName(), kwargs, family); } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index d5f8a7a1..ca3833cd 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/ThinkingLevelHolder.java b/mateclaw-server/src/main/java/vip/mate/agent/ThinkingLevelHolder.java deleted file mode 100644 index 635957c0..00000000 --- a/mateclaw-server/src/main/java/vip/mate/agent/ThinkingLevelHolder.java +++ /dev/null @@ -1,33 +0,0 @@ -package vip.mate.agent; - -/** - * 请求级思考深度的 ThreadLocal 持有器。 - *

- * 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode, - * 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口。 - *

- * 支持的值:off / low / medium / high / max,null 表示跟随模型默认。 - * - * @author MateClaw Team - */ -public final class ThinkingLevelHolder { - - private static final ThreadLocal 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(); - } -} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index c74d5264..0ec166cc 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -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 getBoundSkillIds(Long agentId) { List bindings = listSkillBindings(agentId); if (bindings.isEmpty()) { @@ -668,6 +670,7 @@ public class AgentBindingService { *

Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the * fallback chain order per agent.

*/ + @Override public List getPreferredProviderIds(Long agentId) { if (agentId == null) return Collections.emptyList(); return listProviderPreferences(agentId).stream() diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java deleted file mode 100644 index 32a48031..00000000 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java +++ /dev/null @@ -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 observationRegistryProvider; - - public AgentOpenAiCompatibleChatModelBuilder( - @Lazy AgentGraphBuilder agentGraphBuilder, - ObjectProvider 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; - } -} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOriginHolder.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOriginHolder.java index 86d3db8f..1fc1bdca 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOriginHolder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOriginHolder.java @@ -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 { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index 7b3fba58..e59b5a18 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -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); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index d6e22e00..a378b95c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -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 diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 293a48ac..468e500b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java index a0747d6b..f3949f99 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java @@ -13,7 +13,7 @@ import java.util.Optional; *

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

Behavior

*
    diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java similarity index 89% rename from mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java index e91756f4..9df1b5a9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java @@ -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; * *

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

    + * 4k/8k/16k/32k thinking tokens) and prompt-cache options.

    */ @Slf4j @Component -public class AgentAnthropicChatModelBuilder implements ChatModelBuilder { +public class AnthropicChatModelBuilder implements ChatModelBuilder { private final ModelProviderService modelProviderService; private final ObjectProvider restClientBuilderProvider; @@ -43,7 +40,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder { private final ObjectProvider observationRegistryProvider; private final AnthropicCacheOptionsFactory anthropicCacheOptionsFactory; - public AgentAnthropicChatModelBuilder( + public AnthropicChatModelBuilder( ModelProviderService modelProviderService, ObjectProvider restClientBuilderProvider, ObjectProvider 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. * - *

    Package-private + static so {@code AgentClaudeCodeChatModelBuilder} - * (RFC-062) can apply the same timeouts to its OAuth RestClient without - * duplicating the snippet.

    + *

    Package-private + static so {@code ClaudeCodeChatModelBuilder} can + * apply the same timeouts to its OAuth RestClient without duplicating + * the snippet.

    */ 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). *

    - * 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) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AssistantThinkingRelay.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AssistantThinkingRelay.java similarity index 96% rename from mateclaw-server/src/main/java/vip/mate/agent/AssistantThinkingRelay.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AssistantThinkingRelay.java index 88736364..b11de1f8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AssistantThinkingRelay.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AssistantThinkingRelay.java @@ -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}). * *

    Why not {@link ThreadLocal}: {@code OpenAiChatModel.stream()} hops to * {@code boundedElastic} via {@code subscribeOn}, so a {@code ThreadLocal} on the diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeChatModelBuilder.java similarity index 91% rename from mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeChatModelBuilder.java index ae3f8f47..70fb0f24 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeChatModelBuilder.java @@ -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}. * *

    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 restClientBuilderProvider; @@ -66,8 +65,8 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { private final ObjectProvider observationRegistryProvider; private final ObjectMapper objectMapper; - public AgentClaudeCodeChatModelBuilder( - AgentAnthropicChatModelBuilder anthropicBuilder, + public ClaudeCodeChatModelBuilder( + AnthropicChatModelBuilder anthropicBuilder, ClaudeCodeOAuthService oauthService, ClaudeCodeApiHeaders apiHeaders, ObjectProvider 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) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecorator.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeIdentityChatModelDecorator.java similarity index 97% rename from mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecorator.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeIdentityChatModelDecorator.java index 96cecbd2..c849f4db 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecorator.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeIdentityChatModelDecorator.java @@ -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. * *

    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; *

  1. Sporadic 500s on the first call after a long idle period.
  2. * * - *

    Reference: hermes-agent {@code anthropic_adapter._build_anthropic_messages_request} - * lines 1571-1607 — same transforms applied unconditionally on - * {@code is_oauth=True} requests. - * *

    Transforms applied per call

    *
      *
    1. System prompt prefix: prepend diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java similarity index 98% rename from mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java index 36130b35..79102140 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java @@ -1,4 +1,4 @@ -package vip.mate.agent.chatmodel; +package vip.mate.llm.chatmodel; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeSystemArrayInterceptor.java similarity index 99% rename from mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeSystemArrayInterceptor.java index 8470a0eb..c3d899ff 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ClaudeCodeSystemArrayInterceptor.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DashScopeChatModelBuilder.java similarity index 96% rename from mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DashScopeChatModelBuilder.java index e512681a..37099067 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DashScopeChatModelBuilder.java @@ -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; * *

      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.

      + * row is incomplete, so the agent package no longer carries any DashScope + * schema knowledge.

      * *

      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 dashScopeChatModelProvider; private final DashScopeConnectionProperties dashScopeConnectionProperties; private final ModelProviderService modelProviderService; - public AgentDashScopeChatModelBuilder(ObjectProvider dashScopeChatModelProvider, + public DashScopeChatModelBuilder(ObjectProvider dashScopeChatModelProvider, DashScopeConnectionProperties dashScopeConnectionProperties, ModelProviderService modelProviderService) { this.dashScopeChatModelProvider = dashScopeChatModelProvider; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecorator.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DeepSeekV4ThinkingDecorator.java similarity index 91% rename from mateclaw-server/src/main/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecorator.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DeepSeekV4ThinkingDecorator.java index aebad848..80cd9ebb 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecorator.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/DeepSeekV4ThinkingDecorator.java @@ -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. * *

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

      Reference: openclaw {@code plugin-sdk/provider-stream-shared.ts} - * lines 185-213 ({@code createDeepSeekV4OpenAICompatibleThinkingWrapper}). - * *

      Pipeline (per request)

      *
        *
      1. Read {@link ThinkingLevelHolder} for the current request's thinking - * level (set by AgentService before the call).
      2. + * level (set by the agent service before the call). *
      3. 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.
      4. *
      5. Delegate to the wrapped {@link ChatModel}.
      6. *
      - * - *

      Spring AI 1.1.4's {@link OpenAiChatOptions} exposes a public - * {@code extraBody: Map} (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"; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/HttpTimeouts.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/HttpTimeouts.java index 4376f67a..7334ea9f 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/HttpTimeouts.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/HttpTimeouts.java @@ -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. * - *

      Used by: - *

        - *
      • {@code AgentAnthropicChatModelBuilder.applyHttpTimeouts}
      • - *
      • {@code AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient}
      • - *
      • {@code AgentClaudeCodeChatModelBuilder} (via Anthropic helper)
      • - *
      • {@code AgentGraphBuilder} legacy timeout helpers
      • - *
      + *

      Used by the OpenAI-compatible, Anthropic and Claude Code chat model + * builders to apply consistent connect / read timeouts. * *

      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); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java new file mode 100644 index 00000000..4700101b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java @@ -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}. + * + *

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

      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 restClientBuilderProvider; + private final ObjectProvider webClientBuilderProvider; + private final ObjectProvider observationRegistryProvider; + + public OpenAiCompatibleChatModelBuilder( + ModelProviderService modelProviderService, + ObjectMapper objectMapper, + ObjectProvider restClientBuilderProvider, + ObjectProvider webClientBuilderProvider, + ObjectProvider 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 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 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 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 kwargs = modelProviderService.readProviderGenerateKwargs(provider); + MultiValueMap 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 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 chatCompletionEntity( + OpenAiApi.ChatCompletionRequest chatRequest, + MultiValueMap 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 chatCompletionStream( + OpenAiApi.ChatCompletionRequest chatRequest, + MultiValueMap 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 buildOpenAiHeaders(Map kwargs) { + LinkedMultiValueMap 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 extractOverrideHeaders(Map kwargs) { + Map 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 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. + * + *

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

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

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

      {@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. + * + *

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

      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: + *

        + *
      1. {@link AssistantThinkingRelay#take(String)} the entry and restore + * {@code request.user()} to {@code entry.originalUser()} so the + * internal token never reaches the provider.
      2. + *
      3. 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.
      4. + *
      5. 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.
      6. + *
      + * + *

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

        + *
      • {@code emptyFallback}: value to inject when the relay has no real + * value — {@code null} means leave {@code reasoning_content} null; + * {@code " "} preserves legacy tolerance.
      • + *
      • {@code warnOnMissingReal}: emit WARN when {@code emptyFallback==null} + * fires.
      • + *
      • {@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.
      • + *
      • {@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.
      • + *
      + * + *

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

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

      {@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. + * + *

      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) ? "" : 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. + * + *

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

      Per the OpenAI spec, omitting {@code tool_choice} when {@code tools} is + * non-empty is equivalent to {@code "auto"}. Stripping the explicit literal: + *

        + *
      • does not change behavior on compliant servers — they still default to + * auto when tools are present;
      • + *
      • 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.
      • + *
      + * + *

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

      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 patched = request.messages().stream().map(msg -> { + if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.USER || !(msg.rawContent() instanceof List parts)) { + return msg; + } + List 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. + * + *

      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 webSearchTool = Map.of( + "type", "builtin_function", + "function", Map.of("name", "$web_search") + ); + + // Convert existing tools to List and append $web_search + List> allTools = new ArrayList<>(); + if (request.tools() != null) { + for (OpenAiApi.FunctionTool tool : request.tools()) { + Map toolMap = new LinkedHashMap<>(); + toolMap.put("type", "function"); + if (tool.getFunction() != null) { + Map 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 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 + ); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ProviderGenerateKwargs.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ProviderGenerateKwargs.java new file mode 100644 index 00000000..25c3a6f7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ProviderGenerateKwargs.java @@ -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. + * + *

      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 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 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) 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 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 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/RateLimitDiagnosticExchangeFilter.java similarity index 98% rename from mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/RateLimitDiagnosticExchangeFilter.java index f3c0a2da..11e8cde3 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/RateLimitDiagnosticExchangeFilter.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/RateLimitDiagnosticInterceptor.java similarity index 99% rename from mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java rename to mateclaw-server/src/main/java/vip/mate/llm/chatmodel/RateLimitDiagnosticInterceptor.java index b0d7402e..6bb3a03e 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/RateLimitDiagnosticInterceptor.java @@ -1,4 +1,4 @@ -package vip.mate.agent.chatmodel; +package vip.mate.llm.chatmodel; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningEffortResolver.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningEffortResolver.java new file mode 100644 index 00000000..4a95118a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningEffortResolver.java @@ -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}. + * + *

      Resolution rules: + *

        + *
      • 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.
      • + *
      • For accepting families, an explicit {@code reasoningEffort} in the + * provider's generate kwargs wins.
      • + *
      • Otherwise a thinking-capable family gets a default of {@code "medium"}.
      • + *
      + * + *

      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 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ThinkingLevelHolder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ThinkingLevelHolder.java new file mode 100644 index 00000000..1f9d7ee8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ThinkingLevelHolder.java @@ -0,0 +1,36 @@ +package vip.mate.llm.chatmodel; + +/** + * Request-scoped {@link ThreadLocal} holder for the thinking depth. + * + *

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

      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 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(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java index 73f20aca..09bbe302 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java @@ -39,7 +39,7 @@ public class ModelConfigEntity { * RFC-03 Lane B1 — per-model HTTP read timeout (seconds). * *

      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. * diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java index 0ab8ceea..e9f42a5c 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java @@ -53,7 +53,7 @@ public enum ModelFamily { * 也不强制 temperature=1。OpenClaw 实现参考 {@code extensions/deepseek/models.ts:28-81} 标记 * {@code supportsReasoningEffort: true}。
      * 约束:保留 max_tokens;支持 reasoning_effort;temperature/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), diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java index 6b6c2e44..8d9017dd 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java @@ -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"), diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java new file mode 100644 index 00000000..7c778eec --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java @@ -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. + * + *

      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 getBoundSkillIds(Long agentId); + + /** + * Provider ids the agent prefers, in priority order; empty when none. + */ + List getPreferredProviderIds(Long agentId); +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java index db03ea30..f4891ccf 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java @@ -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. * - *

      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: + *

      Given an agent's bound skills, aggregates the {@code requires-model} + * capabilities they declare and uses that to: + *

        + *
      • {@link #diagnosePrimary} — WARN when the chosen primary model is + * missing a capability the bound skills require;
      • + *
      • {@link #reorderForCapabilities} — lift providers that satisfy the + * required modalities to the head of the fallback chain;
      • + *
      • {@link #selectPrimary} — pick a primary model that satisfies the + * required modalities, falling back to the global default.
      • + *
      * - *
        - *
      1. Aggregates {@code requires-model} from the agent's bound skills' - * manifests.
      2. - *
      3. Compares the union against the primary model's resolved - * capability set ({@link ModelCapabilityService#resolve}).
      4. - *
      5. Logs a clear WARN if a capability is missing — surfacing the - * same gap RFC-085's "ready" badge would render in UI.
      6. - *
      - * - *

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

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

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

      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 reorderForCapabilities(Long agentId, List ordered) { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java deleted file mode 100644 index d8e90124..00000000 --- a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java +++ /dev/null @@ -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. - * - *

      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"); - } -} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderClaude47Test.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderClaude47Test.java new file mode 100644 index 00000000..87c031d8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderClaude47Test.java @@ -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. + * + *

      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"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AssistantThinkingRelayTest.java similarity index 95% rename from mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AssistantThinkingRelayTest.java index 63f858fe..96d01890 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AssistantThinkingRelayTest.java @@ -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 { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeChatModelBuilderTest.java similarity index 94% rename from mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeChatModelBuilderTest.java index a5bd62fc..a4829abd 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeChatModelBuilderTest.java @@ -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); } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java similarity index 99% rename from mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java index 3356f1a1..f16e7e26 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java @@ -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; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekV4ThinkingDecoratorTest.java similarity index 97% rename from mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekV4ThinkingDecoratorTest.java index 73e8b1b2..273221d5 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekV4ThinkingDecoratorTest.java @@ -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")); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java similarity index 90% rename from mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java index b28bc1b7..f8c96473 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java @@ -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)}. * *

      Covers four orthogonal dimensions: *

        @@ -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"); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningEffortSanitizerTest.java similarity index 73% rename from mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningEffortSanitizerTest.java index 762b15e2..e90f2e2b 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningEffortSanitizerTest.java @@ -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}. * *

        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()); } } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java index 36d055d1..9e7bf899 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java @@ -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()); } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java index 8339ad56..b60cc33c 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java @@ -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"); diff --git a/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java b/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java index 7d9f4967..abb77359 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java @@ -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