diff --git a/internal/attackchain/builder.go b/internal/attackchain/builder.go index 4fa4365e..7543a4e6 100644 --- a/internal/attackchain/builder.go +++ b/internal/attackchain/builder.go @@ -301,7 +301,7 @@ func (b *Builder) formatProcessDetailsForAttackChain(details []database.ProcessD // 目标:以主 agent(编排器)视角输出整轮迭代 // - 保留:编排器工具调用/结果、对子代理的 task 调度、子代理最终回复(不含推理) // - 丢弃:thinking/planning/progress 等噪声、子代理的工具细节与推理过程 - if d.EventType == "progress" || d.EventType == "thinking" || d.EventType == "planning" { + if d.EventType == "progress" || d.EventType == "thinking" || d.EventType == "reasoning_chain" || d.EventType == "planning" { continue } diff --git a/internal/handler/agent.go b/internal/handler/agent.go index d3c3fe58..805dd5c7 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -19,6 +19,7 @@ import ( "cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/reasoning" "cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp/builtin" "cyberstrike-ai/internal/multiagent" @@ -201,6 +202,14 @@ type ChatAttachment struct { ServerPath string `json:"serverPath,omitempty"` // 已保存在 chat_uploads 下的绝对路径(由 POST /api/chat-uploads 返回) } +// ChatReasoningRequest 对话页「模型推理」意图(仅 Eino 路径消费;原生 agent-loop 忽略)。 +type ChatReasoningRequest struct { + // Mode: default(跟随系统)| off | on | auto + Mode string `json:"mode,omitempty"` + // Effort: low | medium | high | max;空表示不指定(由系统默认与各 profile 决定)。 + Effort string `json:"effort,omitempty"` +} + // ChatRequest 聊天请求 type ChatRequest struct { Message string `json:"message" binding:"required"` @@ -209,10 +218,18 @@ type ChatRequest struct { Attachments []ChatAttachment `json:"attachments,omitempty"` WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具 Hitl *HITLRequest `json:"hitl,omitempty"` + Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"` // Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。 Orchestration string `json:"orchestration,omitempty"` } +func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientIntent { + if r == nil { + return nil + } + return &reasoning.ClientIntent{Mode: r.Mode, Effort: r.Effort} +} + type HITLRequest struct { Enabled bool `json:"enabled"` Mode string `json:"mode,omitempty"` @@ -567,14 +584,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) { h.logger.Warn("获取历史消息失败", zap.Error(err)) agentHistoryMessages = []agent.ChatMessage{} } else { - // 将数据库消息转换为Agent消息格式 - agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages)) - for _, msg := range historyMessages { - agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{ - Role: msg.Role, - Content: msg.Content, - }) - } + agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages) h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages))) } } else { @@ -775,6 +785,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI progressCallback, h.agentsMarkdownDir, "deep", + nil, ) if errMA != nil { if shouldPersistEinoAgentTraceAfterRunError(ctx) { @@ -788,17 +799,8 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI return "", conversationID, errMA } if assistantMessageID != "" { - mcpIDsJSON := "" - if len(resultMA.MCPExecutionIDs) > 0 { - jsonData, _ := json.Marshal(resultMA.MCPExecutionIDs) - mcpIDsJSON = string(jsonData) - } - _, err = h.db.Exec( - "UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?", - resultMA.Response, mcpIDsJSON, time.Now(), assistantMessageID, - ) - if err != nil { - h.logger.Warn("机器人:更新助手消息失败", zap.Error(err)) + if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil { + h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU)) } } else { if _, err = h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil { @@ -823,17 +825,8 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI // 更新助手消息内容与 MCP 执行 ID(与 stream 一致) if assistantMessageID != "" { - mcpIDsJSON := "" - if len(result.MCPExecutionIDs) > 0 { - jsonData, _ := json.Marshal(result.MCPExecutionIDs) - mcpIDsJSON = string(jsonData) - } - _, err = h.db.Exec( - "UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?", - result.Response, mcpIDsJSON, time.Now(), assistantMessageID, - ) - if err != nil { - h.logger.Warn("机器人:更新助手消息失败", zap.Error(err)) + if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)); errU != nil { + h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU)) } } else { if _, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs); err != nil { @@ -891,10 +884,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun return "" } - // thinking_stream_*:不逐条落库,按 streamId 聚合,在后续关键事件前补一条可持久化的 thinking + // thinking_stream_*(ReAct 等助手正文流)与 reasoning_chain_stream_*(Eino ReasoningContent): + // 不逐条落库,按 streamId 聚合,flush 时分别落 thinking / reasoning_chain。 type thinkingBuf struct { - b strings.Builder - meta map[string]interface{} + b strings.Builder + meta map[string]interface{} + persistAs string // "thinking" | "reasoning_chain" } thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf flushedThinking := make(map[string]bool) // streamId -> flushed @@ -948,8 +943,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun } data[k] = v } - if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "thinking", content, data); err != nil { - h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "thinking")) + persist := tb.persistAs + if persist != "reasoning_chain" { + persist = "thinking" + } + if err := h.db.AddProcessDetail(assistantMessageID, conversationID, persist, content, data); err != nil { + h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", persist)) } flushedThinking[sid] = true } @@ -1177,14 +1176,20 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun return } - // 聚合 thinking_stream_*(ReasoningContent),不逐条落库 - if eventType == "thinking_stream_start" { + // 聚合 thinking_stream_* / reasoning_chain_stream_*,不逐条落库 + if eventType == "thinking_stream_start" || eventType == "reasoning_chain_stream_start" { + persistAs := "thinking" + if eventType == "reasoning_chain_stream_start" { + persistAs = "reasoning_chain" + } if dataMap, ok := data.(map[string]interface{}); ok { if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" { tb := thinkingStreams[sid] if tb == nil { - tb = &thinkingBuf{meta: map[string]interface{}{}} + tb = &thinkingBuf{meta: map[string]interface{}{}, persistAs: persistAs} thinkingStreams[sid] = tb + } else { + tb.persistAs = persistAs } // 记录元信息(source/einoAgent/einoRole/iteration 等) for k, v := range dataMap { @@ -1194,15 +1199,21 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun } return } - if eventType == "thinking_stream_delta" { + if eventType == "thinking_stream_delta" || eventType == "reasoning_chain_stream_delta" { + persistAs := "thinking" + if eventType == "reasoning_chain_stream_delta" { + persistAs = "reasoning_chain" + } if dataMap, ok := data.(map[string]interface{}); ok { if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" { tb := thinkingStreams[sid] if tb == nil { - tb = &thinkingBuf{meta: map[string]interface{}{}} + tb = &thinkingBuf{meta: map[string]interface{}{}, persistAs: persistAs} thinkingStreams[sid] = tb + } else if tb.persistAs == "" { + tb.persistAs = persistAs } - // delta 片段直接拼接;message 本身就是 reasoning content + // delta 片段直接拼接 tb.b.WriteString(message) // 有时 delta 先到 start 未到,补充元信息 for k, v := range dataMap { @@ -1213,10 +1224,9 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun return } - // 当 Agent 同时发送 thinking_stream_* 和 thinking(带同一 streamId)时, - // thinking_stream_* 已经会在 flushThinkingStreams() 聚合落库; - // 这里跳过同 streamId 的 thinking,避免 processDetails 双份展示。 - if eventType == "thinking" { + // 当 Agent 同时发送 *_stream_* 与同名 streamId 的 thinking/reasoning_chain 时, + // 流式聚合已会在 flushThinkingStreams() 落库;此处跳过逐条重复。 + if eventType == "thinking" || eventType == "reasoning_chain" { if dataMap, ok := data.(map[string]interface{}); ok { if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" { if tb, exists := thinkingStreams[sid]; exists && tb != nil { @@ -1245,7 +1255,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun if eventType == "tool_result" { discardPlanningIfEchoesToolResult(&respPlan, data) } - // 在关键过程事件落库前,先把「规划中」与 thinking_stream 落库 + // 在关键过程事件落库前,先把「规划中」与聚合中的 thinking / reasoning_chain 流落库 flushResponsePlan() flushThinkingStreams() if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil { @@ -1427,14 +1437,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) { h.logger.Warn("获取历史消息失败", zap.Error(err)) agentHistoryMessages = []agent.ChatMessage{} } else { - // 将数据库消息转换为Agent消息格式 - agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages)) - for _, msg := range historyMessages { - agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{ - Role: msg.Role, - Content: msg.Content, - }) - } + agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages) h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages))) } } else { @@ -1727,20 +1730,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) { // 更新助手消息内容 if assistantMsg != nil { - _, err = h.db.Exec( - "UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?", - result.Response, - func() string { - if len(result.MCPExecutionIDs) > 0 { - jsonData, _ := json.Marshal(result.MCPExecutionIDs) - return string(jsonData) - } - return "" - }(), - time.Now(), assistantMessageID, - ) - if err != nil { - h.logger.Error("更新助手消息失败", zap.Error(err)) + if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)); errU != nil { + h.logger.Error("更新助手消息失败", zap.Error(errU)) } } else { // 如果之前创建失败,现在创建 @@ -2664,12 +2655,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) { var runErr error switch { case useBatchMulti: - resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch) + resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil) case useEinoSingle: if h.config == nil { runErr = fmt.Errorf("服务器配置未加载") } else { - resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback) + resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil) } default: result, runErr = h.agent.AgentLoopWithProgress(taskCtx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools) @@ -2768,17 +2759,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) { // 更新助手消息内容 if assistantMessageID != "" { - mcpIDsJSON := "" - if len(mcpIDs) > 0 { - jsonData, _ := json.Marshal(mcpIDs) - mcpIDsJSON = string(jsonData) - } - if _, updateErr := h.db.Exec( - "UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?", - resText, - mcpIDsJSON, - time.Now(), assistantMessageID, - ); updateErr != nil { + if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil { h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr)) // 如果更新失败,尝试创建新消息 _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs) @@ -2870,6 +2851,10 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent if content, ok := msgMap["content"].(string); ok { msg.Content = content } + // DeepSeek 思考模式:含工具调用的 assistant 须在后续请求中回传 reasoning_content + if rc, ok := msgMap["reasoning_content"].(string); ok && strings.TrimSpace(rc) != "" { + msg.ReasoningContent = rc + } // 解析tool_calls(如果存在) if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil { @@ -2975,3 +2960,18 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent ) return agentMessages, nil } + +// dbMessagesToAgentChatMessages maps DB rows to agent ChatMessage for history fallback +// (includes reasoning_content for DeepSeek thinking + tool replay). +func dbMessagesToAgentChatMessages(msgs []database.Message) []agent.ChatMessage { + out := make([]agent.ChatMessage, 0, len(msgs)) + for i := range msgs { + m := msgs[i] + out = append(out, agent.ChatMessage{ + Role: m.Role, + Content: m.Content, + ReasoningContent: m.ReasoningContent, + }) + } + return out +} diff --git a/internal/handler/config.go b/internal/handler/config.go index 59f9b78c..1ff0c607 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -1312,6 +1312,19 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) { if cfg.MaxTotalTokens > 0 { setIntInMap(openaiNode, "max_total_tokens", cfg.MaxTotalTokens) } + rn := ensureMap(openaiNode, "reasoning") + if strings.TrimSpace(cfg.Reasoning.Mode) != "" { + setStringInMap(rn, "mode", cfg.Reasoning.Mode) + } + if strings.TrimSpace(cfg.Reasoning.Effort) != "" { + setStringInMap(rn, "effort", cfg.Reasoning.Effort) + } + if cfg.Reasoning.AllowClientReasoning != nil { + setBoolInMap(rn, "allow_client_reasoning", *cfg.Reasoning.AllowClientReasoning) + } + if strings.TrimSpace(cfg.Reasoning.Profile) != "" { + setStringInMap(rn, "profile", cfg.Reasoning.Profile) + } } func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) { diff --git a/internal/handler/eino_single_agent.go b/internal/handler/eino_single_agent.go index 1bce56af..8ffd757e 100644 --- a/internal/handler/eino_single_agent.go +++ b/internal/handler/eino_single_agent.go @@ -196,6 +196,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) { curHistory, roleTools, progressCallback, + chatReasoningToClientIntent(req.Reasoning), ) timeoutCancel() @@ -297,18 +298,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) { } if assistantMessageID != "" { - mcpIDsJSON := "" - if len(cumulativeMCPExecutionIDs) > 0 { - jsonData, _ := json.Marshal(cumulativeMCPExecutionIDs) - mcpIDsJSON = string(jsonData) - } - _, _ = h.db.Exec( - "UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?", - result.Response, - mcpIDsJSON, - time.Now(), - assistantMessageID, - ) + _ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)) } if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" { @@ -376,6 +366,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) { prep.History, prep.RoleTools, progressCallback, + chatReasoningToClientIntent(req.Reasoning), ) if runErr != nil { if shouldPersistEinoAgentTraceAfterRunError(baseCtx) { @@ -386,18 +377,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) { } if prep.AssistantMessageID != "" { - mcpIDsJSON := "" - if len(result.MCPExecutionIDs) > 0 { - jsonData, _ := json.Marshal(result.MCPExecutionIDs) - mcpIDsJSON = string(jsonData) - } - _, _ = h.db.Exec( - "UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?", - result.Response, - mcpIDsJSON, - time.Now(), - prep.AssistantMessageID, - ) + _ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)) } if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" { _ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput) diff --git a/internal/handler/multi_agent.go b/internal/handler/multi_agent.go index 4278119d..142a7755 100644 --- a/internal/handler/multi_agent.go +++ b/internal/handler/multi_agent.go @@ -208,6 +208,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) { progressCallback, h.agentsMarkdownDir, orch, + chatReasoningToClientIntent(req.Reasoning), ) timeoutCancel() @@ -309,18 +310,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) { } if assistantMessageID != "" { - mcpIDsJSON := "" - if len(cumulativeMCPExecutionIDs) > 0 { - jsonData, _ := json.Marshal(cumulativeMCPExecutionIDs) - mcpIDsJSON = string(jsonData) - } - _, _ = h.db.Exec( - "UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?", - result.Response, - mcpIDsJSON, - time.Now(), - assistantMessageID, - ) + _ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)) } if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" { @@ -390,6 +380,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) { progressCallback, h.agentsMarkdownDir, strings.TrimSpace(req.Orchestration), + chatReasoningToClientIntent(req.Reasoning), ) if runErr != nil { if shouldPersistEinoAgentTraceAfterRunError(baseCtx) { @@ -405,18 +396,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) { } if prep.AssistantMessageID != "" { - mcpIDsJSON := "" - if len(result.MCPExecutionIDs) > 0 { - jsonData, _ := json.Marshal(result.MCPExecutionIDs) - mcpIDsJSON = string(jsonData) - } - _, _ = h.db.Exec( - "UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?", - result.Response, - mcpIDsJSON, - time.Now(), - prep.AssistantMessageID, - ) + _ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)) } if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" { diff --git a/internal/handler/multi_agent_prepare.go b/internal/handler/multi_agent_prepare.go index 51703e86..0d35ee7c 100644 --- a/internal/handler/multi_agent_prepare.go +++ b/internal/handler/multi_agent_prepare.go @@ -55,13 +55,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr if getErr != nil { agentHistoryMessages = []agent.ChatMessage{} } else { - agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages)) - for _, msg := range historyMessages { - agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{ - Role: msg.Role, - Content: msg.Content, - }) - } + agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages) } } diff --git a/internal/reasoning/eino.go b/internal/reasoning/eino.go new file mode 100644 index 00000000..397ac526 --- /dev/null +++ b/internal/reasoning/eino.go @@ -0,0 +1,250 @@ +// Package reasoning maps user/config intent to CloudWeGo Eino OpenAI ChatModel fields +// (ReasoningEffort, ExtraFields such as thinking / reasoning_effort / output_config). +package reasoning + +import ( + "strings" + + "cyberstrike-ai/internal/config" + + einoopenai "github.com/cloudwego/eino-ext/components/model/openai" +) + +// ClientIntent is optional per-request override from ChatRequest.reasoning. +type ClientIntent struct { + Mode string + Effort string +} + +type wireProfile int + +const ( + wireNone wireProfile = iota + wireClaude + wireDeepseek + wireOpenAI + wireOutputConfig +) + +// ApplyToEinoChatModelConfig merges reasoning-related options into cfg. +// Precondition: cfg already has APIKey, BaseURL, Model, HTTPClient set. +func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig, client *ClientIntent) { + if cfg == nil || oa == nil { + return + } + sr := &oa.Reasoning + allowClient := sr.AllowClientReasoningEffective() + mode := effectiveMode(sr, client, allowClient) + + // Claude (Anthropic): merge admin extras first; optional extended thinking maps to top-level `thinking` + // (see internal/openai convertOpenAIToClaude). DeepSeek/OpenAI-style fields are not sent. + if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") || + strings.EqualFold(strings.TrimSpace(oa.Provider), "anthropic") { + if len(sr.ExtraRequestFields) > 0 { + if cfg.ExtraFields == nil { + cfg.ExtraFields = make(map[string]any) + } + for k, v := range sr.ExtraRequestFields { + cfg.ExtraFields[k] = v + } + } + if mode == "off" { + return + } + applyClaudeExtendedThinking(cfg, mode, effectiveEffort(sr, client, allowClient), oa.Model) + return + } + + if mode == "off" { + return + } + effort := effectiveEffort(sr, client, allowClient) + prof := resolveWireProfile(oa, sr) + + // Admin-defined extra root fields (merged first; automatic keys may follow). + if len(sr.ExtraRequestFields) > 0 { + if cfg.ExtraFields == nil { + cfg.ExtraFields = make(map[string]any) + } + for k, v := range sr.ExtraRequestFields { + cfg.ExtraFields[k] = v + } + } + + switch prof { + case wireClaude, wireNone: + return + case wireDeepseek: + applyDeepseek(cfg, mode, effort) + case wireOutputConfig: + applyOutputConfigEffort(cfg, mode, effort) + default: // wireOpenAI + applyOpenAICompat(cfg, mode, effort) + } +} + +// applyClaudeExtendedThinking sets Anthropic Messages API `thinking` when absent from ExtraRequestFields. +// Uses adaptive + summarized display by default (per Anthropic guidance for Claude 4.x); Sonnet 3.7 uses enabled+budget. +func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, model string) { + if cfg == nil || mode == "off" { + return + } + if cfg.ExtraFields == nil { + cfg.ExtraFields = make(map[string]any) + } + if _, exists := cfg.ExtraFields["thinking"]; exists { + return + } + m := strings.ToLower(strings.TrimSpace(model)) + thinking := map[string]any{ + "type": "adaptive", + "display": "summarized", + } + // Sonnet 3.7: manual extended thinking is the documented path. + if strings.Contains(m, "claude-3-7-sonnet") || strings.Contains(m, "3-7-sonnet") || strings.Contains(m, "sonnet-3.7") { + thinking = map[string]any{ + "type": "enabled", + "budget_tokens": 10000, + "display": "summarized", + } + } + // Opus 4.7+: manual enabled+budget rejected — keep adaptive only. + if strings.Contains(m, "opus-4-7") || strings.Contains(m, "opus-4.7") { + thinking = map[string]any{ + "type": "adaptive", + "display": "summarized", + } + } + _ = effort // reserved: map to Anthropic effort / output_config when API stabilizes in one place + cfg.ExtraFields["thinking"] = thinking +} + +func effectiveMode(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string { + server := strings.ToLower(strings.TrimSpace(sr.ModeEffective())) + if server == "" || server == "default" { + server = "auto" + } + if !allowClient || client == nil { + return server + } + cm := strings.ToLower(strings.TrimSpace(client.Mode)) + if cm == "" || cm == "default" { + return server + } + return cm +} + +func effectiveEffort(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string { + se := normalizeEffort(sr.Effort) + if !allowClient || client == nil { + return se + } + ce := normalizeEffort(client.Effort) + if ce != "" { + return ce + } + return se +} + +func normalizeEffort(s string) string { + e := strings.ToLower(strings.TrimSpace(s)) + switch e { + case "low", "medium", "high", "max": + return e + default: + return "" + } +} + +func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfig) wireProfile { + if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") { + return wireClaude + } + p := strings.ToLower(strings.TrimSpace(sr.ProfileEffective())) + switch p { + case "output_config", "output_config_effort": + return wireOutputConfig + case "openai", "openai_compat": + return wireOpenAI + case "deepseek", "deepseek_compat": + return wireDeepseek + case "auto", "": + bu := strings.ToLower(oa.BaseURL) + mo := strings.ToLower(oa.Model) + if strings.Contains(bu, "deepseek") || strings.Contains(mo, "deepseek") { + return wireDeepseek + } + return wireOpenAI + default: + return wireOpenAI + } +} + +func applyDeepseek(cfg *einoopenai.ChatModelConfig, mode, effort string) { + // auto: enable thinking for DeepSeek line; on: same; auto without effort still opens thinking. + if mode == "off" { + return + } + if mode == "auto" || mode == "on" { + if cfg.ExtraFields == nil { + cfg.ExtraFields = make(map[string]any) + } + cfg.ExtraFields["thinking"] = map[string]any{"type": "enabled"} + } + if effort != "" { + if cfg.ExtraFields == nil { + cfg.ExtraFields = make(map[string]any) + } + cfg.ExtraFields["reasoning_effort"] = effortStringForAPI(effort) + } +} + +func applyOpenAICompat(cfg *einoopenai.ChatModelConfig, mode, effort string) { + if mode == "auto" && effort == "" { + return + } + e := effort + if mode == "on" && e == "" { + e = "medium" + } + if e == "" { + return + } + if e == "max" { + if cfg.ExtraFields == nil { + cfg.ExtraFields = make(map[string]any) + } + cfg.ExtraFields["reasoning_effort"] = "max" + return + } + switch e { + case "low": + cfg.ReasoningEffort = einoopenai.ReasoningEffortLevelLow + case "medium": + cfg.ReasoningEffort = einoopenai.ReasoningEffortLevelMedium + case "high": + cfg.ReasoningEffort = einoopenai.ReasoningEffortLevelHigh + } +} + +func applyOutputConfigEffort(cfg *einoopenai.ChatModelConfig, mode, effort string) { + if mode == "auto" && effort == "" { + return + } + e := effort + if mode == "on" && e == "" { + e = "high" + } + if e == "" { + return + } + if cfg.ExtraFields == nil { + cfg.ExtraFields = make(map[string]any) + } + cfg.ExtraFields["output_config"] = map[string]any{"effort": effortStringForAPI(e)} +} + +func effortStringForAPI(e string) string { + // Gateways expect lowercase strings; "max" kept as max. + return strings.ToLower(strings.TrimSpace(e)) +}