mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
1513 lines
76 KiB
Java
1513 lines
76 KiB
Java
package vip.mate.agent;
|
||
|
||
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
|
||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
|
||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||
import com.alibaba.cloud.ai.dashscope.spec.DashScopeApiSpec;
|
||
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
|
||
import com.alibaba.cloud.ai.graph.CompiledGraph;
|
||
import com.alibaba.cloud.ai.graph.CompileConfig;
|
||
import com.alibaba.cloud.ai.graph.KeyStrategy;
|
||
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;
|
||
import org.springframework.ai.anthropic.AnthropicChatModel;
|
||
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||
import org.springframework.ai.anthropic.api.AnthropicApi;
|
||
import org.springframework.ai.chat.client.ChatClient;
|
||
import org.springframework.ai.chat.model.ChatModel;
|
||
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.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.agent.graph.StateGraphReActAgent;
|
||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||
import vip.mate.agent.graph.edge.ObservationDispatcher;
|
||
import vip.mate.agent.graph.edge.ReasoningDispatcher;
|
||
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
|
||
import vip.mate.agent.graph.node.*;
|
||
import vip.mate.agent.graph.observation.ObservationProcessor;
|
||
import vip.mate.agent.graph.plan.StateGraphPlanExecuteAgent;
|
||
import vip.mate.agent.graph.plan.edge.PlanGenerationDispatcher;
|
||
import vip.mate.agent.graph.plan.edge.StepProgressDispatcher;
|
||
import vip.mate.agent.graph.plan.node.*;
|
||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||
import vip.mate.agent.model.AgentEntity;
|
||
import vip.mate.config.GraphObservationProperties;
|
||
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.ModelConfigService;
|
||
import vip.mate.llm.service.ModelProviderService;
|
||
import vip.mate.planning.service.PlanningService;
|
||
import vip.mate.skill.service.SkillService;
|
||
import vip.mate.system.service.SystemSettingService;
|
||
import vip.mate.tool.ToolRegistry;
|
||
import vip.mate.workspace.document.WorkspaceFileService;
|
||
import vip.mate.tool.guard.service.ToolGuardService;
|
||
import vip.mate.workspace.conversation.ConversationService;
|
||
import vip.mate.approval.ApprovalWorkflowService;
|
||
import vip.mate.channel.web.ChatStreamTracker;
|
||
|
||
import java.lang.reflect.Field;
|
||
import java.util.ArrayList;
|
||
import java.util.LinkedHashMap;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
|
||
/**
|
||
* Agent 图构建器
|
||
* <p>
|
||
* 纯构建器,不做执行。从 AgentService 中提取出所有 Agent 实例构建逻辑,
|
||
* 包括模型创建、图编译、prompt 增强等。
|
||
*
|
||
* @author MateClaw Team
|
||
*/
|
||
@Slf4j
|
||
@Component
|
||
@RequiredArgsConstructor
|
||
public class AgentGraphBuilder {
|
||
|
||
private final ToolRegistry toolRegistry;
|
||
private final SkillService skillService;
|
||
private final vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
|
||
private final ConversationService conversationService;
|
||
private final ModelConfigService modelConfigService;
|
||
private final ModelProviderService modelProviderService;
|
||
private final PlanningService planningService;
|
||
private final ToolGuardService toolGuardService;
|
||
private final vip.mate.tool.guard.service.ToolGuardConfigService toolGuardConfigService;
|
||
private final ApprovalWorkflowService approvalService;
|
||
private final ChatStreamTracker streamTracker;
|
||
private final SystemSettingService systemSettingService;
|
||
private final DashScopeChatModel dashScopeChatModel;
|
||
private final DashScopeConnectionProperties dashScopeConnectionProperties;
|
||
private final RetryTemplate retryTemplate;
|
||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
|
||
private final ObjectMapper objectMapper;
|
||
private final GraphObservationProperties graphObservationProperties;
|
||
private final WorkspaceFileService workspaceFileService;
|
||
private final vip.mate.agent.context.ConversationWindowManager conversationWindowManager;
|
||
|
||
/**
|
||
* 根据 AgentEntity 构建完整的 Agent 实例
|
||
*/
|
||
public BaseAgent build(AgentEntity entity) {
|
||
AgentToolSet toolSet = toolRegistry.getEnabledToolSet();
|
||
|
||
// 过滤掉 denied 工具,使模型完全看不到它们(防止 prompt injection 利用 schema)
|
||
toolSet = toolSet.withDeniedToolsFiltered(toolGuardConfigService.getDeniedTools());
|
||
|
||
// 统一使用全局默认模型(AgentEntity.modelName 为历史残留字段,不参与运行时选择)
|
||
ModelConfigEntity runtimeModel;
|
||
try {
|
||
runtimeModel = modelConfigService.getDefaultModel();
|
||
} catch (Exception e) {
|
||
throw new MateClawException("无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型");
|
||
}
|
||
|
||
ModelProviderEntity provider;
|
||
try {
|
||
provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
|
||
} catch (Exception e) {
|
||
throw new MateClawException("模型 " + runtimeModel.getModelName()
|
||
+ " 的 Provider(" + runtimeModel.getProvider() + ")未配置,请检查模型设置");
|
||
}
|
||
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
|
||
|
||
// 内置搜索:DashScope 或 Kimi 开启时,移除 WebSearchTool 避免冲突
|
||
boolean builtinSearchEnabled = false;
|
||
Map<String, Object> providerKwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||
if (protocol == ModelProtocol.DASHSCOPE_NATIVE) {
|
||
builtinSearchEnabled = isDashScopeSearchEnabled(runtimeModel, provider);
|
||
} else if (isKimiProvider(provider) && Boolean.TRUE.equals(providerKwargs.get("enableSearch"))) {
|
||
builtinSearchEnabled = true;
|
||
}
|
||
if (builtinSearchEnabled) {
|
||
int before = toolSet.size();
|
||
toolSet = toolSet.excluding(Set.of("search"));
|
||
log.info("内置搜索已开启 (provider={}), 移除 WebSearchTool (tools: {} -> {})",
|
||
provider.getProviderId(), before, toolSet.size());
|
||
}
|
||
int maxIter = entity.getMaxIterations() != null ? entity.getMaxIterations() : 25;
|
||
|
||
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled);
|
||
|
||
// 当前仅支持 DashScope 和 OpenAI-compatible,其他协议直接拒绝
|
||
if (!supportsStateGraph(protocol)) {
|
||
throw new MateClawException("当前不支持协议 " + protocol.getId()
|
||
+ ",请切换到 DashScope 或 OpenAI-compatible 模型");
|
||
}
|
||
|
||
BaseAgent agent;
|
||
boolean toolCallingEnabled;
|
||
if ("plan_execute".equals(entity.getAgentType())) {
|
||
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter);
|
||
toolCallingEnabled = true;
|
||
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
|
||
entity.getName(), maxIter, toolSet.size(), protocol.getId());
|
||
} else {
|
||
agent = buildReActAgent(toolSet, runtimeModel, maxIter);
|
||
// StateGraph 路径下工具调用由 ActionNode 控制,始终启用
|
||
toolCallingEnabled = true;
|
||
log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})",
|
||
entity.getName(), maxIter, toolSet.size(), protocol.getId());
|
||
}
|
||
|
||
// 设置通用属性
|
||
agent.agentId = String.valueOf(entity.getId());
|
||
agent.agentName = entity.getName();
|
||
agent.systemPrompt = enhancedPrompt;
|
||
agent.maxIterations = maxIter;
|
||
agent.modelName = runtimeModel.getModelName();
|
||
agent.runtimeProviderId = provider != null ? provider.getProviderId() : "";
|
||
agent.temperature = runtimeModel.getTemperature();
|
||
agent.maxTokens = runtimeModel.getMaxTokens();
|
||
agent.maxInputTokens = runtimeModel.getMaxInputTokens();
|
||
agent.topP = runtimeModel.getTopP();
|
||
agent.toolCallingEnabled = toolCallingEnabled;
|
||
|
||
log.info("Built agent instance: {} (type={}, protocol={}, tools={}, toolCallingEnabled={})",
|
||
entity.getName(), entity.getAgentType(), protocol.getId(),
|
||
toolSet.size(), agent.toolCallingEnabled);
|
||
return agent;
|
||
}
|
||
|
||
// ==================== Agent 构建方法 ====================
|
||
|
||
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
|
||
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||
ChatClient chatClient = ChatClient.create(chatModel);
|
||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort);
|
||
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
|
||
chatModel, conversationWindowManager);
|
||
}
|
||
|
||
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
|
||
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||
ChatClient chatClient = ChatClient.create(chatModel);
|
||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort);
|
||
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
||
chatModel, conversationWindowManager);
|
||
}
|
||
|
||
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
||
try {
|
||
ChatModel fallbackModel = buildFallbackModel(chatModel);
|
||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackModel);
|
||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker);
|
||
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager);
|
||
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager);
|
||
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
|
||
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
|
||
|
||
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
|
||
// 共享键
|
||
.addStrategy(MateClawStateKeys.PENDING_EVENTS, KeyStrategy.APPEND)
|
||
.addStrategy(MateClawStateKeys.CURRENT_PHASE, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.SYSTEM_PROMPT, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.CONVERSATION_ID, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.TRACE_ID, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.AGENT_ID, KeyStrategy.REPLACE)
|
||
// 会话消息(复用 ReAct 的 MESSAGES key,APPEND 策略)
|
||
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
|
||
// Plan 特有键
|
||
.addStrategy(PlanStateKeys.GOAL, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.PLAN_ID, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.PLAN_STEPS, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.PLAN_VALID, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.NEEDS_PLANNING, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.CURRENT_STEP_INDEX, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.CURRENT_STEP_TITLE, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.CURRENT_STEP_RESULT, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.COMPLETED_RESULTS, KeyStrategy.APPEND)
|
||
.addStrategy(PlanStateKeys.FINAL_SUMMARY, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.DIRECT_ANSWER, KeyStrategy.REPLACE)
|
||
// 工作上下文(REPLACE 策略,每次重新生成)
|
||
.addStrategy(PlanStateKeys.WORKING_CONTEXT, KeyStrategy.REPLACE)
|
||
// Thinking 键
|
||
.addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE)
|
||
.addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE)
|
||
// 流式防重键
|
||
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
|
||
// 流式内容暂存(AWAITING_APPROVAL 路径持久化使用)
|
||
.addStrategy(MateClawStateKeys.STREAMED_CONTENT, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.STREAMED_THINKING, KeyStrategy.REPLACE)
|
||
// 请求者身份(审批身份校验使用)
|
||
.addStrategy(MateClawStateKeys.REQUESTER_ID, KeyStrategy.REPLACE)
|
||
// 审批重放键
|
||
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.PRE_APPROVED_TOOL_CALL, KeyStrategy.REPLACE)
|
||
// Token Usage
|
||
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
|
||
.build();
|
||
|
||
// Graph 拓扑:
|
||
// START → PLAN_GENERATION → (PlanGenerationDispatcher)
|
||
// ├→ DIRECT_ANSWER_NODE → END
|
||
// └→ STEP_EXECUTION → (StepProgressDispatcher)
|
||
// ├→ STEP_EXECUTION (loop)
|
||
// └→ PLAN_SUMMARY → END
|
||
|
||
StateGraph graph = new StateGraph("plan-execute-agent", keyStrategyFactory)
|
||
.addNode(PlanStateKeys.PLAN_GENERATION_NODE,
|
||
AsyncNodeAction.node_async(planGenerationNode))
|
||
.addNode(PlanStateKeys.STEP_EXECUTION_NODE,
|
||
AsyncNodeAction.node_async(stepExecutionNode))
|
||
.addNode(PlanStateKeys.PLAN_SUMMARY_NODE,
|
||
AsyncNodeAction.node_async(planSummaryNode))
|
||
.addNode(PlanStateKeys.DIRECT_ANSWER_NODE,
|
||
AsyncNodeAction.node_async(directAnswerNode))
|
||
.addEdge(StateGraph.START, PlanStateKeys.PLAN_GENERATION_NODE)
|
||
.addConditionalEdges(PlanStateKeys.PLAN_GENERATION_NODE,
|
||
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
|
||
Map.of(
|
||
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
|
||
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE))
|
||
.addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE,
|
||
AsyncEdgeAction.edge_async(new StepProgressDispatcher()),
|
||
Map.of(
|
||
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
|
||
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE,
|
||
StateGraph.END, StateGraph.END))
|
||
.addEdge(PlanStateKeys.PLAN_SUMMARY_NODE, StateGraph.END)
|
||
.addEdge(PlanStateKeys.DIRECT_ANSWER_NODE, StateGraph.END);
|
||
|
||
return graph.compile(CompileConfig.builder()
|
||
.recursionLimit(maxIterations * 3 + 10)
|
||
.build());
|
||
} catch (Exception e) {
|
||
throw new MateClawException("Plan-Execute StateGraph 编译失败: " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
||
try {
|
||
ChatModel fallbackModel = buildFallbackModel(chatModel);
|
||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackModel);
|
||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker);
|
||
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, streamTracker);
|
||
ActionNode actionNode = new ActionNode(executor, streamTracker);
|
||
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
|
||
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
|
||
SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker);
|
||
LimitExceededNode limitExceededNode = new LimitExceededNode(chatModel, observationProcessor, streamingHelper);
|
||
FinalAnswerNode finalAnswerNode = new FinalAnswerNode();
|
||
|
||
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
|
||
// 输入字段
|
||
.addStrategy(MateClawStateKeys.USER_MESSAGE, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.CONVERSATION_ID, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.SYSTEM_PROMPT, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.AGENT_ID, KeyStrategy.REPLACE)
|
||
// 消息列表(追加策略)
|
||
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
|
||
// 迭代控制
|
||
.addStrategy(MateClawStateKeys.CURRENT_ITERATION, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.MAX_ITERATIONS, KeyStrategy.REPLACE)
|
||
// 工具调用
|
||
.addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.TOOL_RESULTS, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.TOOL_CALL_COUNT, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
||
// 控制流
|
||
.addStrategy(MateClawStateKeys.FINAL_ANSWER, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.NEEDS_TOOL_CALL, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.ERROR, KeyStrategy.REPLACE)
|
||
// 观察历史(REPLACE 策略,由 ObservationNode 手动累加,SummarizingNode 可清空)
|
||
.addStrategy(MateClawStateKeys.OBSERVATION_HISTORY, KeyStrategy.REPLACE)
|
||
// Summarizing
|
||
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
|
||
// 终止控制
|
||
.addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.LIMIT_EXCEEDED, KeyStrategy.REPLACE)
|
||
// 统计与追踪
|
||
.addStrategy(MateClawStateKeys.ERROR_COUNT, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.TRACE_ID, KeyStrategy.REPLACE)
|
||
// 事件流
|
||
.addStrategy(MateClawStateKeys.PENDING_EVENTS, KeyStrategy.APPEND)
|
||
.addStrategy(MateClawStateKeys.CURRENT_PHASE, KeyStrategy.REPLACE)
|
||
// Thinking
|
||
.addStrategy(MateClawStateKeys.FINAL_THINKING, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.CURRENT_THINKING, KeyStrategy.REPLACE)
|
||
// 流式防重
|
||
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
|
||
// 审批控制
|
||
.addStrategy(MateClawStateKeys.AWAITING_APPROVAL, KeyStrategy.REPLACE)
|
||
// 流式内容暂存(AWAITING_APPROVAL 路径持久化使用)
|
||
.addStrategy(MateClawStateKeys.STREAMED_CONTENT, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.STREAMED_THINKING, KeyStrategy.REPLACE)
|
||
// 请求者身份(审批身份校验使用)
|
||
.addStrategy(MateClawStateKeys.REQUESTER_ID, KeyStrategy.REPLACE)
|
||
// 审批重放
|
||
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
|
||
// Token Usage
|
||
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
|
||
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
|
||
.build();
|
||
|
||
StateGraph graph = new StateGraph("react-agent-v2", keyStrategyFactory)
|
||
.addNode(MateClawStateKeys.REASONING_NODE,
|
||
AsyncNodeAction.node_async(reasoningNode))
|
||
.addNode(MateClawStateKeys.ACTION_NODE,
|
||
AsyncNodeAction.node_async(actionNode))
|
||
.addNode(MateClawStateKeys.OBSERVATION_NODE,
|
||
AsyncNodeAction.node_async(observationNode))
|
||
.addNode(MateClawStateKeys.SUMMARIZING_NODE,
|
||
AsyncNodeAction.node_async(summarizingNode))
|
||
.addNode(MateClawStateKeys.LIMIT_EXCEEDED_NODE,
|
||
AsyncNodeAction.node_async(limitExceededNode))
|
||
.addNode(MateClawStateKeys.FINAL_ANSWER_NODE,
|
||
AsyncNodeAction.node_async(finalAnswerNode))
|
||
.addEdge(StateGraph.START, MateClawStateKeys.REASONING_NODE)
|
||
.addConditionalEdges(MateClawStateKeys.REASONING_NODE,
|
||
AsyncEdgeAction.edge_async(new ReasoningDispatcher()),
|
||
Map.of(MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE,
|
||
MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE,
|
||
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE,
|
||
MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE))
|
||
.addEdge(MateClawStateKeys.ACTION_NODE, MateClawStateKeys.OBSERVATION_NODE)
|
||
.addConditionalEdges(MateClawStateKeys.OBSERVATION_NODE,
|
||
AsyncEdgeAction.edge_async(new ObservationDispatcher()),
|
||
Map.of(MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
|
||
MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE,
|
||
MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE,
|
||
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE))
|
||
.addEdge(MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.REASONING_NODE)
|
||
.addEdge(MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.FINAL_ANSWER_NODE)
|
||
.addEdge(MateClawStateKeys.FINAL_ANSWER_NODE, StateGraph.END);
|
||
|
||
return graph.compile(CompileConfig.builder()
|
||
.recursionLimit(maxIterations * 3 + 10)
|
||
.withLifecycleListener(new ReActLifecycleListener())
|
||
.build());
|
||
} catch (Exception e) {
|
||
throw new MateClawException("StateGraph v2 编译失败: " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
// ==================== 协议能力判断 ====================
|
||
|
||
private boolean supportsStateGraph(ModelProtocol protocol) {
|
||
return protocol == ModelProtocol.DASHSCOPE_NATIVE
|
||
|| protocol == ModelProtocol.OPENAI_COMPATIBLE
|
||
|| protocol == ModelProtocol.ANTHROPIC_MESSAGES;
|
||
}
|
||
|
||
// ==================== 模型构建 ====================
|
||
|
||
/**
|
||
* 构建运行时 ChatModel(不包装为 ChatClient)
|
||
* 用于 StateGraph 节点直接调用
|
||
*/
|
||
public ChatModel buildRuntimeChatModel(ModelConfigEntity runtimeModel) {
|
||
ModelProviderEntity provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
|
||
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
|
||
|
||
if (protocol == ModelProtocol.DASHSCOPE_NATIVE) {
|
||
DashScopeApi api = buildDashScopeApi(provider);
|
||
DashScopeChatOptions options = buildDashScopeOptions(runtimeModel, provider);
|
||
return dashScopeChatModel.mutate()
|
||
.dashScopeApi(api)
|
||
.defaultOptions(options)
|
||
.build();
|
||
}
|
||
|
||
if (protocol == ModelProtocol.OPENAI_COMPATIBLE) {
|
||
OpenAiApi api = buildOpenAiApi(provider);
|
||
OpenAiChatOptions options = buildOpenAiOptions(runtimeModel, provider);
|
||
return OpenAiChatModel.builder()
|
||
.openAiApi(api)
|
||
.defaultOptions(options)
|
||
.retryTemplate(retryTemplate)
|
||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||
.build();
|
||
}
|
||
|
||
if (protocol == ModelProtocol.ANTHROPIC_MESSAGES) {
|
||
AnthropicApi api = buildAnthropicApi(provider);
|
||
AnthropicChatOptions options = buildAnthropicOptions(runtimeModel);
|
||
return AnthropicChatModel.builder()
|
||
.anthropicApi(api)
|
||
.defaultOptions(options)
|
||
.retryTemplate(retryTemplate)
|
||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||
.build();
|
||
}
|
||
|
||
throw new MateClawException("StateGraph 当前仅支持 DashScope 原生协议、OpenAI-compatible 协议和 Anthropic Messages 协议: " + protocol.getId());
|
||
}
|
||
|
||
/**
|
||
* 构建 fallback 模型:优先使用 UI 配置的 DashScope provider key 构建新实例,
|
||
* 避免直接依赖 Spring 注入的 dashScopeChatModel bean(它只读环境变量)。
|
||
*/
|
||
ChatModel buildFallbackModel(ChatModel primaryModel) {
|
||
try {
|
||
ModelProviderEntity dashScopeProvider = modelProviderService.getProviderConfig("dashscope");
|
||
DashScopeApi api = buildDashScopeApi(dashScopeProvider);
|
||
ModelConfigEntity fallbackModelConfig = modelConfigService.getDefaultModelByProvider("dashscope");
|
||
DashScopeChatOptions options = buildDashScopeOptions(
|
||
fallbackModelConfig != null ? fallbackModelConfig : modelConfigService.getDefaultModel(), dashScopeProvider);
|
||
ChatModel fallback = dashScopeChatModel.mutate()
|
||
.dashScopeApi(api)
|
||
.defaultOptions(options)
|
||
.build();
|
||
return (fallback != primaryModel) ? fallback : null;
|
||
} catch (Exception e) {
|
||
log.warn("无法构建 DashScope fallback 模型(UI 配置和环境变量均无可用 key),将跳过 fallback: {}", e.getMessage());
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判断 DashScope 内置搜索是否开启:默认开启,仅当显式设为 false 时关闭
|
||
*/
|
||
private boolean isDashScopeSearchEnabled(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||
// provider generateKwargs 中的 enableSearch 优先级最高(UI 开关直接控制)
|
||
Object kwargsSearch = kwargs.get("enableSearch");
|
||
if (kwargsSearch != null) {
|
||
return Boolean.TRUE.equals(kwargsSearch);
|
||
}
|
||
// model 级别字段:null 视为未设置(DashScope 默认开启),false 视为显式关闭
|
||
if (Boolean.FALSE.equals(runtimeModel.getEnableSearch())) {
|
||
// DB DEFAULT FALSE 导致已有行为 false,此时如果是 DashScope 仍默认开启
|
||
// 只有用户手动设置过才会有明确含义,但目前无法区分,所以 DashScope 默认开启
|
||
return true;
|
||
}
|
||
return true; // DashScope 默认开启
|
||
}
|
||
|
||
// ==================== Prompt 构建 ====================
|
||
|
||
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
|
||
// 优先从工作区 MD 文件组装系统提示词
|
||
String workspacePrompt = workspaceFileService.buildSystemPrompt(entity.getId());
|
||
String basePrompt = (workspacePrompt != null && !workspacePrompt.isBlank())
|
||
? workspacePrompt
|
||
: (entity.getSystemPrompt() != null ? entity.getSystemPrompt() : "");
|
||
|
||
// 使用 skill runtime 构建技能增强(分层注入,不再全量拼接)
|
||
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement();
|
||
|
||
// 工具调用指导
|
||
String toolGuidance = """
|
||
|
||
## Runtime Context
|
||
- Current Agent ID: %s
|
||
|
||
## Workspace Memory Guidelines
|
||
Your durable memory is stored in database-backed workspace markdown files for this agent:
|
||
- `PROFILE.md`: stable user profile, preferences, collaboration style
|
||
- `MEMORY.md`: distilled long-term memory, durable facts, lessons, recurring patterns
|
||
- `memory/YYYY-MM-DD.md`: daily notes, raw events, temporary observations, open loops
|
||
|
||
Use workspace memory tools instead of local filesystem tools for those files:
|
||
- `list_workspace_memory_files(agentId=..., filenamePrefix=...)`
|
||
- `read_workspace_memory_file(agentId=..., filename=...)`
|
||
- `write_workspace_memory_file(agentId=..., filename=..., content=...)`
|
||
- `edit_workspace_memory_file(agentId=..., filename=..., oldText=..., newText=...)`
|
||
|
||
Memory writing policy:
|
||
- Stable user preference, identity, collaboration habit -> `PROFILE.md`
|
||
- Stable project fact, workflow, tool setup, lesson learned, recurring decision -> `MEMORY.md`
|
||
- One-off event, meeting note, temporary context, today's decision trace -> `memory/YYYY-MM-DD.md`
|
||
- Read before write unless you are creating a brand new daily note
|
||
- Do not store secrets or highly sensitive data unless the user explicitly asks
|
||
- Updating workspace memory files is internal state maintenance for this agent and can be done proactively when useful
|
||
|
||
Memory emergence policy:
|
||
- If the same preference, constraint, workflow, or lesson appears repeatedly, consolidate it from daily notes into `MEMORY.md`
|
||
- Prefer updating an existing section over appending duplicate bullets
|
||
- Treat `MEMORY.md` as a compact mental model, not a raw transcript dump
|
||
- When answering tasks involving prior decisions, preferences, habits, or ongoing work, proactively consult relevant workspace memory first
|
||
|
||
## Tool Usage Guidelines
|
||
When you have available tools, use them to access local system information, files, or execute commands.
|
||
Do not assume you cannot access local resources - try calling the appropriate tool first.
|
||
If a tool requires approval due to security policies, the system will prompt the user for confirmation.
|
||
Only state you cannot access something if no relevant tool is available.
|
||
|
||
## File Reading Guidelines
|
||
|
||
**Text Files** (use read_file):
|
||
For .txt, .md, .json, .yaml, .csv, .log, .py, .java, .js, .html, .xml, .sql, .conf, .ini, .toml files.
|
||
|
||
**Office/PDF Documents** (DO NOT use read_file):
|
||
For .pdf, .docx, .doc, .xlsx, .xls, .pptx, .ppt files, NEVER use read_file.
|
||
Instead use:
|
||
- detect_file_type(filePath="...") - to check file type first
|
||
- extract_document_text(filePath="...") - general document extraction
|
||
- extract_pdf_text(filePath="...") - for PDF files
|
||
- extract_docx_text(filePath="...") - for Word documents
|
||
|
||
Example workflow for document:
|
||
1. detect_file_type(filePath="/path/to/document.pdf")
|
||
2. Based on result, use extract_pdf_text() or extract_document_text()
|
||
3. Process the extracted text content
|
||
|
||
If you try to read a PDF/Office file with read_file, you will get binary garbage or an error.
|
||
""".formatted(entity.getId());
|
||
|
||
String searchGuidance = "";
|
||
if (builtinSearchEnabled) {
|
||
searchGuidance = """
|
||
|
||
## Built-in Web Search (IMPORTANT)
|
||
You have built-in web search capability enabled by the model provider. Your responses automatically incorporate live web search results.
|
||
|
||
### Rules
|
||
- **直接回答** — 不要调用 browser_use、search 或任何其他工具进行网页搜索。
|
||
- **不要说你无法搜索** — 你的回复已自动融合实时搜索结果。
|
||
- 当用户要求"联网搜索"、"查最新新闻"时,直接生成包含搜索结果的回答。
|
||
|
||
### 新闻搜索策略
|
||
当用户要求查新闻时:
|
||
1. 根据分类构造搜索意图(科技、财经、国际等)
|
||
2. 直接回答,内容自动包含实时搜索结果
|
||
3. 按格式输出:`📰 [分类] 标题 — 来源 | 时间 + 摘要`
|
||
4. 每个分类最多 5 条,优先展示最新内容
|
||
""";
|
||
}
|
||
|
||
return basePrompt + skillEnhancement + toolGuidance + searchGuidance;
|
||
}
|
||
|
||
// ==================== 模型选项构建 ====================
|
||
|
||
private DashScopeChatOptions buildDashScopeOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||
DashScopeChatOptions.DashScopeChatOptionsBuilder builder = DashScopeChatOptions.builder();
|
||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||
|
||
if (StringUtils.hasText(runtimeModel.getModelName())) {
|
||
builder.withModel(runtimeModel.getModelName());
|
||
}
|
||
if (runtimeModel.getTemperature() != null) {
|
||
builder.withTemperature(runtimeModel.getTemperature());
|
||
}
|
||
if (runtimeModel.getMaxTokens() != null) {
|
||
builder.withMaxToken(runtimeModel.getMaxTokens());
|
||
}
|
||
if (runtimeModel.getTopP() != null) {
|
||
builder.withTopP(runtimeModel.getTopP());
|
||
}
|
||
// 内置搜索:复用统一判断方法
|
||
if (isDashScopeSearchEnabled(runtimeModel, provider)) {
|
||
builder.withEnableSearch(true);
|
||
String strategy = runtimeModel.getSearchStrategy();
|
||
if (!StringUtils.hasText(strategy)) {
|
||
strategy = (String) kwargs.get("searchStrategy");
|
||
}
|
||
if (StringUtils.hasText(strategy)) {
|
||
builder.withSearchOptions(DashScopeApiSpec.SearchOptions.builder()
|
||
.searchStrategy(strategy)
|
||
.enableSource(true)
|
||
.enableCitation(true)
|
||
.build());
|
||
}
|
||
}
|
||
return builder.build();
|
||
}
|
||
|
||
private OpenAiChatOptions buildOpenAiOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||
OpenAiChatOptions.Builder builder = OpenAiChatOptions.builder();
|
||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||
String modelName = runtimeModel.getModelName();
|
||
ModelFamily family = ModelFamily.detect(modelName);
|
||
|
||
if (StringUtils.hasText(modelName)) {
|
||
builder.model(modelName);
|
||
}
|
||
|
||
// temperature:部分模型族强制 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 构建 ====================
|
||
|
||
OpenAiApi buildOpenAiApi(ModelProviderEntity provider) {
|
||
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
|
||
throw new MateClawException("Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
|
||
}
|
||
String apiKey = provider.getApiKey();
|
||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||
throw new MateClawException("Provider API Key 未配置或无效: " + provider.getProviderId());
|
||
}
|
||
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
|
||
if (!StringUtils.hasText(baseUrl)) {
|
||
throw new MateClawException("Provider Base URL 未配置: " + provider.getProviderId());
|
||
}
|
||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||
MultiValueMap<String, String> headers = buildOpenAiHeaders(kwargs);
|
||
String completionsPath = resolveOpenAiCompletionsPath(baseUrl, kwargs);
|
||
RestClient.Builder restClientBuilder = restClientBuilderProvider.getIfAvailable(RestClient::builder);
|
||
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
|
||
|
||
// Spring AI OpenAiApi 构造函数会先 set User-Agent 为 "spring-ai",再 addAll 我们的 headers,
|
||
// 导致自定义 User-Agent 被追加而非覆盖。因此对需要伪装客户端身份的 provider(如 kimi-code),
|
||
// 通过 RestClient/WebClient 拦截器在请求发出前强制覆盖 headers。
|
||
Map<String, String> overrideHeaders = extractOverrideHeaders(kwargs);
|
||
if (!overrideHeaders.isEmpty()) {
|
||
restClientBuilder = restClientBuilder.requestInterceptor((request, body, execution) -> {
|
||
HttpHeaders reqHeaders = request.getHeaders();
|
||
overrideHeaders.forEach(reqHeaders::set);
|
||
return execution.execute(request, body);
|
||
});
|
||
webClientBuilder = webClientBuilder.filter((request, next) -> {
|
||
org.springframework.web.reactive.function.client.ClientRequest modified =
|
||
org.springframework.web.reactive.function.client.ClientRequest.from(request)
|
||
.headers(h -> overrideHeaders.forEach(h::set))
|
||
.build();
|
||
return next.exchange(modified);
|
||
});
|
||
}
|
||
|
||
boolean kimiSearchEnabled = isKimiProvider(provider)
|
||
&& Boolean.TRUE.equals(kwargs.get("enableSearch"));
|
||
|
||
return new OpenAiApi(
|
||
baseUrl,
|
||
new SimpleApiKey(apiKey.trim()),
|
||
headers,
|
||
completionsPath,
|
||
"/v1/embeddings",
|
||
restClientBuilder,
|
||
webClientBuilder,
|
||
RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER) {
|
||
@Override
|
||
public org.springframework.http.ResponseEntity<OpenAiApi.ChatCompletion> chatCompletionEntity(
|
||
OpenAiApi.ChatCompletionRequest chatRequest,
|
||
MultiValueMap<String, String> additionalHttpHeader) {
|
||
chatRequest = patchReasoningContent(chatRequest);
|
||
chatRequest = stripReasoningEffortIfIncompatible(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<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
|
||
OpenAiApi.ChatCompletionRequest chatRequest,
|
||
MultiValueMap<String, String> additionalHttpHeader) {
|
||
chatRequest = patchReasoningContent(chatRequest);
|
||
chatRequest = stripReasoningEffortIfIncompatible(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 构建 ====================
|
||
|
||
private DashScopeApi buildDashScopeApi(ModelProviderEntity provider) {
|
||
DashScopeApi.Builder builder = DashScopeApi.builder();
|
||
|
||
// API Key 回落链:provider UI 配置 → 环境变量/application.yml → 默认 bean 反射
|
||
String apiKey = provider != null ? provider.getApiKey() : null;
|
||
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||
apiKey = dashScopeConnectionProperties.getApiKey();
|
||
}
|
||
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||
apiKey = readApiKeyFromDefaultChatModel();
|
||
}
|
||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||
throw new MateClawException("DashScope API Key 未配置,请在模型设置中填写 dashscope 的 API Key,或设置 DASHSCOPE_API_KEY 环境变量");
|
||
}
|
||
builder.apiKey(apiKey.trim());
|
||
|
||
// Base URL 回落链:provider UI 配置 → 环境变量/application.yml → 默认 bean 反射
|
||
String baseUrl = provider != null ? provider.getBaseUrl() : null;
|
||
if (!StringUtils.hasText(baseUrl)) {
|
||
baseUrl = dashScopeConnectionProperties.getBaseUrl();
|
||
}
|
||
if (!StringUtils.hasText(baseUrl)) {
|
||
baseUrl = readBaseUrlFromDefaultChatModel();
|
||
}
|
||
String normalizedBaseUrl = normalizeDashScopeBaseUrl(baseUrl);
|
||
if (StringUtils.hasText(normalizedBaseUrl)) {
|
||
builder.baseUrl(normalizedBaseUrl);
|
||
}
|
||
return builder.build();
|
||
}
|
||
|
||
// ==================== Anthropic API 构建 ====================
|
||
|
||
private AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
|
||
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
|
||
throw new MateClawException("Anthropic Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
|
||
}
|
||
String apiKey = provider.getApiKey();
|
||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||
throw new MateClawException("Anthropic API Key 未配置或无效: " + provider.getProviderId());
|
||
}
|
||
String baseUrl = provider.getBaseUrl();
|
||
RestClient.Builder restClientBuilder = restClientBuilderProvider.getIfAvailable(RestClient::builder);
|
||
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
|
||
|
||
AnthropicApi.Builder builder = AnthropicApi.builder()
|
||
.apiKey(apiKey.trim())
|
||
.restClientBuilder(restClientBuilder)
|
||
.webClientBuilder(webClientBuilder);
|
||
if (StringUtils.hasText(baseUrl)) {
|
||
builder.baseUrl(baseUrl.trim());
|
||
}
|
||
return builder.build();
|
||
}
|
||
|
||
private AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
|
||
AnthropicChatOptions.Builder builder = AnthropicChatOptions.builder();
|
||
if (StringUtils.hasText(runtimeModel.getModelName())) {
|
||
builder.model(runtimeModel.getModelName());
|
||
}
|
||
// Anthropic API does not allow temperature and top_p to be specified simultaneously.
|
||
// Prefer temperature; only fall back to top_p when temperature is absent.
|
||
if (runtimeModel.getTemperature() != null) {
|
||
builder.temperature(runtimeModel.getTemperature());
|
||
} else if (runtimeModel.getTopP() != null) {
|
||
builder.topP(runtimeModel.getTopP());
|
||
}
|
||
if (runtimeModel.getMaxTokens() != null) {
|
||
builder.maxTokens(runtimeModel.getMaxTokens());
|
||
} else {
|
||
// Anthropic requires max_tokens; set a safe default
|
||
builder.maxTokens(4096);
|
||
}
|
||
return builder.internalToolExecutionEnabled(false).build();
|
||
}
|
||
|
||
// ==================== 参数解析辅助方法 ====================
|
||
|
||
private Double resolveOpenAiTemperature(String modelName, Double configuredTemperature,
|
||
Map<String, Object> 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<String, Object> 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<String, Object> kwargs, ModelFamily family) {
|
||
// generateKwargs 显式覆盖始终优先
|
||
Object value = findOptionValue(kwargs, "reasoningEffort");
|
||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||
return text.trim();
|
||
}
|
||
// 仅支持 reasoning_effort 的模型族才自动注入默认值
|
||
if (family.isThinking() && family.supportsReasoningEffort()) {
|
||
return "medium";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private boolean isThinkingModel(String modelName) {
|
||
return ModelFamily.detect(modelName).isThinking();
|
||
}
|
||
|
||
/**
|
||
* 从 ModelConfigEntity 中解析 reasoningEffort,用于传递给 StepExecutionNode / ReasoningNode。
|
||
* 复用已有的 resolveReasoningEffort + isThinkingModel 逻辑。
|
||
*/
|
||
private String resolveReasoningEffortForModel(ModelConfigEntity runtimeModel) {
|
||
ModelProviderEntity provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
|
||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||
ModelFamily family = ModelFamily.detect(runtimeModel.getModelName());
|
||
return resolveReasoningEffort(runtimeModel.getModelName(), kwargs, family);
|
||
}
|
||
|
||
private Double resolveDoubleOption(String key, Double fallback, Map<String, Object> kwargs) {
|
||
Object value = findOptionValue(kwargs, key);
|
||
if (value instanceof Number number) {
|
||
return number.doubleValue();
|
||
}
|
||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||
try {
|
||
return Double.parseDouble(text.trim());
|
||
} catch (NumberFormatException ignored) {
|
||
log.warn("Invalid double generateKwargs value for {}: {}", key, text);
|
||
}
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
private Integer resolveIntegerOption(String key, Integer fallback, Map<String, Object> kwargs) {
|
||
Object value = findOptionValue(kwargs, key);
|
||
if (value instanceof Number number) {
|
||
return number.intValue();
|
||
}
|
||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||
try {
|
||
return Integer.parseInt(text.trim());
|
||
} catch (NumberFormatException ignored) {
|
||
log.warn("Invalid integer generateKwargs value for {}: {}", key, text);
|
||
}
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
@SuppressWarnings("unchecked")
|
||
private Object findOptionValue(Map<String, Object> kwargs, String key) {
|
||
Object direct = findKwarg(kwargs, key);
|
||
if (direct != null) {
|
||
return direct;
|
||
}
|
||
String snakeCase = key.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
|
||
if (!snakeCase.equals(key)) {
|
||
return findKwarg(kwargs, snakeCase);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@SuppressWarnings("unchecked")
|
||
private Object findKwarg(Map<String, Object> kwargs, String key) {
|
||
if (kwargs == null || kwargs.isEmpty()) {
|
||
return null;
|
||
}
|
||
if (kwargs.containsKey(key)) {
|
||
return kwargs.get(key);
|
||
}
|
||
Object chatOptions = kwargs.get("chatOptions");
|
||
if (chatOptions instanceof Map<?, ?> optionsMap) {
|
||
return ((Map<String, Object>) optionsMap).get(key);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ==================== URL 规范化 ====================
|
||
|
||
private String normalizeDashScopeBaseUrl(String baseUrl) {
|
||
if (baseUrl == null || baseUrl.isBlank()) {
|
||
return null;
|
||
}
|
||
String normalized = baseUrl.trim();
|
||
// 去掉 OpenAI 兼容模式路径(用户可能从兼容模式 URL 迁移过来)
|
||
int compatibleIndex = normalized.indexOf("/compatible-mode/");
|
||
if (compatibleIndex >= 0) {
|
||
normalized = normalized.substring(0, compatibleIndex);
|
||
}
|
||
if (normalized.endsWith("/")) {
|
||
normalized = normalized.substring(0, normalized.length() - 1);
|
||
}
|
||
// 如果结果是 DashScope 默认地址,返回 null 让 SDK 使用内置默认值,避免路径拼接问题
|
||
if ("https://dashscope.aliyuncs.com".equals(normalized)) {
|
||
return null;
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
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<String, Object> webSearchTool = Map.of(
|
||
"type", "builtin_function",
|
||
"function", Map.of("name", "$web_search")
|
||
);
|
||
|
||
// 将原有 tools 转为 List<Map> 并追加 $web_search
|
||
List<Map<String, Object>> allTools = new ArrayList<>();
|
||
if (request.tools() != null) {
|
||
for (OpenAiApi.FunctionTool tool : request.tools()) {
|
||
Map<String, Object> toolMap = new LinkedHashMap<>();
|
||
toolMap.put("type", "function");
|
||
if (tool.getFunction() != null) {
|
||
Map<String, Object> funcMap = new LinkedHashMap<>();
|
||
funcMap.put("name", tool.getFunction().getName());
|
||
if (tool.getFunction().getDescription() != null) {
|
||
funcMap.put("description", tool.getFunction().getDescription());
|
||
}
|
||
if (tool.getFunction().getParameters() != null) {
|
||
funcMap.put("parameters", tool.getFunction().getParameters());
|
||
}
|
||
if (tool.getFunction().getStrict() != null) {
|
||
funcMap.put("strict", tool.getFunction().getStrict());
|
||
}
|
||
toolMap.put("function", funcMap);
|
||
}
|
||
allTools.add(toolMap);
|
||
}
|
||
}
|
||
allTools.add(webSearchTool);
|
||
|
||
// 通过 extraBody 注入 tools(覆盖原有 tools 字段),同时清空原 tools 避免重复序列化
|
||
Map<String, Object> extraBody = new LinkedHashMap<>();
|
||
if (request.extraBody() != null) {
|
||
extraBody.putAll(request.extraBody());
|
||
}
|
||
extraBody.put("tools", allTools);
|
||
|
||
return new OpenAiApi.ChatCompletionRequest(
|
||
request.messages(),
|
||
request.model(),
|
||
request.store(),
|
||
request.metadata(),
|
||
request.frequencyPenalty(),
|
||
request.logitBias(),
|
||
request.logprobs(),
|
||
request.topLogprobs(),
|
||
request.maxTokens(),
|
||
request.maxCompletionTokens(),
|
||
request.n(),
|
||
request.outputModalities(),
|
||
request.audioParameters(),
|
||
request.presencePenalty(),
|
||
request.responseFormat(),
|
||
request.seed(),
|
||
request.serviceTier(),
|
||
request.stop(),
|
||
request.stream(),
|
||
request.streamOptions(),
|
||
request.temperature(),
|
||
request.topP(),
|
||
null, // tools — 清空,由 extraBody 接管
|
||
request.toolChoice(),
|
||
request.parallelToolCalls(),
|
||
request.user(),
|
||
request.reasoningEffort(),
|
||
request.webSearchOptions(),
|
||
request.verbosity(),
|
||
request.promptCacheKey(),
|
||
request.safetyIdentifier(),
|
||
extraBody
|
||
);
|
||
}
|
||
|
||
// ==================== 反射读取默认模型配置 ====================
|
||
|
||
private String readApiKeyFromDefaultChatModel() {
|
||
try {
|
||
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||
if (api == null) {
|
||
return null;
|
||
}
|
||
Field apiKeyField = DashScopeApi.class.getDeclaredField("apiKey");
|
||
apiKeyField.setAccessible(true);
|
||
Object apiKey = apiKeyField.get(api);
|
||
if (apiKey instanceof org.springframework.ai.model.ApiKey key) {
|
||
return key.getValue();
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("Failed to read API key from default DashScopeChatModel: {}", e.getMessage());
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private String readBaseUrlFromDefaultChatModel() {
|
||
try {
|
||
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||
if (api == null) {
|
||
return null;
|
||
}
|
||
Field baseUrlField = DashScopeApi.class.getDeclaredField("baseUrl");
|
||
baseUrlField.setAccessible(true);
|
||
Object baseUrl = baseUrlField.get(api);
|
||
return baseUrl instanceof String value ? value : null;
|
||
} catch (Exception e) {
|
||
log.warn("Failed to read baseUrl from default DashScopeChatModel: {}", e.getMessage());
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException {
|
||
Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi");
|
||
apiField.setAccessible(true);
|
||
Object api = apiField.get(dashScopeChatModel);
|
||
return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null;
|
||
}
|
||
|
||
// ==================== 日志辅助 ====================
|
||
|
||
private MultiValueMap<String, String> buildOpenAiHeaders(Map<String, Object> kwargs) {
|
||
LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
|
||
headers.add("User-Agent", "MateClaw/1.0");
|
||
Object headerObject = kwargs.get("headers");
|
||
if (headerObject instanceof Map<?, ?> headerMap) {
|
||
headerMap.forEach((key, value) -> {
|
||
if (key != null && value != null) {
|
||
headers.set(String.valueOf(key), String.valueOf(value));
|
||
}
|
||
});
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
/**
|
||
* 从 generateKwargs.headers 中提取需要强制覆盖的 headers。
|
||
* 用于通过 RestClient/WebClient 拦截器绕过 Spring AI OpenAiApi 的默认 User-Agent。
|
||
*/
|
||
private Map<String, String> extractOverrideHeaders(Map<String, Object> kwargs) {
|
||
Map<String, String> 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;
|
||
}
|
||
|
||
private String resolveOpenAiCompletionsPath(String baseUrl, Map<String, Object> kwargs) {
|
||
Object raw = kwargs.get("completionsPath");
|
||
String path = raw instanceof String value && StringUtils.hasText(value) ? value.trim() : "/v1/chat/completions";
|
||
if (!path.startsWith("/")) {
|
||
path = "/" + path;
|
||
}
|
||
if (baseUrl.endsWith("/v1") && path.startsWith("/v1/")) {
|
||
path = path.substring(3);
|
||
if (!path.startsWith("/")) {
|
||
path = "/" + path;
|
||
}
|
||
}
|
||
return path;
|
||
}
|
||
|
||
/**
|
||
* 修补 assistant 消息缺失的 reasoningContent 字段。
|
||
* <p>
|
||
* Spring AI 1.1.3 在将 AssistantMessage 转回 ChatCompletionMessage 时不会设置 reasoningContent,
|
||
* 导致某些启用 thinking 模式的 API(如 Kimi K2.5)在多轮对话中报错:
|
||
* "thinking is enabled but reasoning_content is missing in assistant tool call message"
|
||
* <p>
|
||
* 触发条件(放宽):
|
||
* <ul>
|
||
* <li>条件 A:请求明确设置了 reasoningEffort</li>
|
||
* <li>条件 B:消息历史中已有 assistant 消息携带 reasoningContent(说明模型天然启用了 thinking)</li>
|
||
* </ul>
|
||
* 修复策略:为缺失 reasoningContent 的 assistant tool_call 消息注入空字符串 "" 以满足 API 校验。
|
||
* 使用 record canonical constructor 重建 ChatCompletionRequest,避免反射修改不可变字段。
|
||
*/
|
||
private static OpenAiApi.ChatCompletionRequest patchReasoningContent(OpenAiApi.ChatCompletionRequest request) {
|
||
if (request.messages() == null || request.messages().isEmpty()) {
|
||
return request;
|
||
}
|
||
|
||
// 判断是否处于 thinking 模式
|
||
boolean thinkingMode = request.reasoningEffort() != null;
|
||
if (!thinkingMode) {
|
||
thinkingMode = requiresReasoningContentPatch(request.model());
|
||
}
|
||
if (!thinkingMode) {
|
||
thinkingMode = request.messages().stream().anyMatch(msg ->
|
||
msg.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
|
||
&& msg.reasoningContent() != null);
|
||
}
|
||
if (!thinkingMode) {
|
||
return request;
|
||
}
|
||
|
||
// 检查是否有需要补丁的消息
|
||
boolean needsPatch = request.messages().stream().anyMatch(msg ->
|
||
msg.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
|
||
&& msg.toolCalls() != null && !msg.toolCalls().isEmpty()
|
||
&& msg.reasoningContent() == null);
|
||
if (!needsPatch) {
|
||
return request;
|
||
}
|
||
|
||
// 重建消息列表,为缺失 reasoningContent 的 assistant tool call 消息注入 ""
|
||
List<OpenAiApi.ChatCompletionMessage> patched = request.messages().stream().map(msg -> {
|
||
if (msg.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
|
||
&& msg.toolCalls() != null && !msg.toolCalls().isEmpty()
|
||
&& msg.reasoningContent() == null) {
|
||
return new OpenAiApi.ChatCompletionMessage(
|
||
msg.rawContent(), msg.role(), msg.name(), msg.toolCallId(),
|
||
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
|
||
msg.annotations(), " ");
|
||
}
|
||
return msg;
|
||
}).toList();
|
||
|
||
// 用 record canonical constructor 重建 request(不用反射)
|
||
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()
|
||
);
|
||
}
|
||
|
||
/**
|
||
* GPT-5 兼容性:在 /v1/chat/completions 路径下,tools 与 reasoning_effort 不可同时存在。
|
||
* <p>
|
||
* 当检测到 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();
|
||
}
|
||
|
||
/**
|
||
* 将 Spring AI 错误地序列化为 image_url 的视频内容块转换为 video_url 格式。
|
||
* <p>
|
||
* Spring AI 1.x 的 MediaContent 没有 video_url 类型,所有非 audio/pdf 的 Media
|
||
* 都被序列化为 image_url。智谱 GLM-5V 等模型要求视频使用 video_url 格式,
|
||
* 否则会报"图片输入格式/解析错误"。
|
||
* <p>
|
||
* 此方法遍历 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<OpenAiApi.ChatCompletionMessage> patched = request.messages().stream().map(msg -> {
|
||
if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.USER || !(msg.rawContent() instanceof List<?> parts)) {
|
||
return msg;
|
||
}
|
||
List<Object> newParts = new ArrayList<>();
|
||
for (Object part : parts) {
|
||
String videoDataUrl = null;
|
||
|
||
// 场景 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());
|
||
}
|
||
}
|