From f89ad1b42d725c0e9157d865d14bd646d1d479c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:00:00 +0800 Subject: [PATCH] Add files via upload --- internal/multiagent/eino_summarize_payload.go | 27 +--- internal/multiagent/runner.go | 18 ++- internal/openai/claude_bridge.go | 9 +- internal/openai/reasoning_payload.go | 79 ++++++++++++ internal/openai/reasoning_payload_test.go | 120 ++++++++++++++++++ .../openai/reasoning_tool_choice_compat.go | 43 +++++++ internal/reasoning/eino.go | 29 +++++ internal/reasoning/eino_test.go | 24 ++++ 8 files changed, 320 insertions(+), 29 deletions(-) create mode 100644 internal/openai/reasoning_payload.go create mode 100644 internal/openai/reasoning_payload_test.go create mode 100644 internal/openai/reasoning_tool_choice_compat.go diff --git a/internal/multiagent/eino_summarize_payload.go b/internal/multiagent/eino_summarize_payload.go index 03372dac..254e7849 100644 --- a/internal/multiagent/eino_summarize_payload.go +++ b/internal/multiagent/eino_summarize_payload.go @@ -1,35 +1,12 @@ package multiagent import ( - "github.com/bytedance/sonic" + copenai "cyberstrike-ai/internal/openai" ) // stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a // chat-completions JSON body. Applied only to summarization Generate calls via // model.ModelOptions on the shared ChatModel — main-agent requests are unchanged. func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) { - var payload map[string]any - if err := sonic.Unmarshal(rawBody, &payload); err != nil { - return rawBody, nil - } - changed := false - for _, key := range []string{ - "thinking", - "reasoning_effort", - "output_config", - "reasoning", - } { - if _, ok := payload[key]; ok { - delete(payload, key) - changed = true - } - } - if !changed { - return rawBody, nil - } - out, err := sonic.Marshal(payload) - if err != nil { - return rawBody, err - } - return out, nil + return copenai.StripReasoningFromChatCompletionBody(rawBody) } diff --git a/internal/multiagent/runner.go b/internal/multiagent/runner.go index db60db6c..0c38c6c8 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -432,6 +432,22 @@ func RunDeepAgent( var da adk.Agent switch orchMode { case "plan_execute": + plannerModelCfg := &einoopenai.ChatModelConfig{ + APIKey: appCfg.OpenAI.APIKey, + BaseURL: strings.TrimSuffix(appCfg.OpenAI.BaseURL, "/"), + Model: appCfg.OpenAI.Model, + HTTPClient: httpClient, + } + reasoning.ApplyPlanExecutePlannerModelConfig(plannerModelCfg, &appCfg.OpenAI) + peMainModel, perr := einoopenai.NewChatModel(ctx, plannerModelCfg) + if perr != nil { + return nil, fmt.Errorf("plan_execute 规划模型: %w", perr) + } + if logger != nil { + logger.Info("plan_execute: planner/replanner 使用无 reasoning 的独立 ChatModel(ToolChoiceForced 兼容)", + zap.String("model", appCfg.OpenAI.Model), + ) + } execModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg) if perr != nil { return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr) @@ -445,7 +461,7 @@ func RunDeepAgent( } } peRoot, perr := NewPlanExecuteRoot(ctx, &PlanExecuteRootArgs{ - MainToolCallingModel: mainModel, + MainToolCallingModel: peMainModel, ExecModel: execModel, OrchInstruction: orchInstruction, ToolsCfg: mainToolsCfg, diff --git a/internal/openai/claude_bridge.go b/internal/openai/claude_bridge.go index c498e060..7ab8bf38 100644 --- a/internal/openai/claude_bridge.go +++ b/internal/openai/claude_bridge.go @@ -806,10 +806,12 @@ func isClaudeProvider(cfg *config.OpenAIConfig) bool { // Eino HTTP Client Bridge // ============================================================ -// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含两层 transport 包装: -// 1. 当 cfg.Provider 为 claude 时,最内层套 claudeRoundTripper,把 OpenAI /chat/completions 透明 +// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含多层 transport 包装: +// 1. 当 cfg.Provider 为 claude 时,套 claudeRoundTripper,把 OpenAI /chat/completions 透明 // 桥接为 Anthropic /v1/messages(并把 Claude SSE 翻译回 OpenAI SSE 格式)。 -// 2. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行 +// 2. reasoningToolChoiceCompatRoundTripper:tool_choice=required/object 时剥离 thinking 字段,避免 +// plan_execute replanner 等强制工具调用与推理模式冲突(部分网关返回 400)。 +// 3. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行 // (": keepalive" / "event: ping" / "retry: 3000" 等),避免 Eino 用的 meguminnnnnnnnn/go-openai // SDK 在累计超过 300 个非 "data:" 行后抛 "stream has sent too many empty messages"。 // @@ -825,6 +827,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client if transport == nil { transport = http.DefaultTransport } + transport = &reasoningToolChoiceCompatRoundTripper{base: transport} if isClaudeProvider(cfg) { transport = &claudeRoundTripper{ base: transport, diff --git a/internal/openai/reasoning_payload.go b/internal/openai/reasoning_payload.go new file mode 100644 index 00000000..dfb827ce --- /dev/null +++ b/internal/openai/reasoning_payload.go @@ -0,0 +1,79 @@ +package openai + +import ( + "github.com/bytedance/sonic" +) + +// reasoningPayloadKeys are OpenAI-compatible root fields that enable "thinking" / +// extended-reasoning modes on gateways such as DashScope/Qwen and MiniMax. +var reasoningPayloadKeys = []string{ + "thinking", + "reasoning_effort", + "output_config", + "reasoning", +} + +// StripReasoningFromChatCompletionBody removes thinking / reasoning fields from a +// chat-completions JSON body. +func StripReasoningFromChatCompletionBody(rawBody []byte) ([]byte, error) { + var payload map[string]any + if err := sonic.Unmarshal(rawBody, &payload); err != nil { + return rawBody, nil + } + if !stripReasoningFields(payload) { + return rawBody, nil + } + out, err := sonic.Marshal(payload) + if err != nil { + return rawBody, err + } + return out, nil +} + +// StripReasoningIfForcedToolChoice removes thinking / reasoning fields when the +// request sets tool_choice to "required" or an object. Several providers reject +// that combination (e.g. DashScope: "tool_choice does not support being set to +// required or object in thinking mode"). +func StripReasoningIfForcedToolChoice(rawBody []byte) ([]byte, error) { + var payload map[string]any + if err := sonic.Unmarshal(rawBody, &payload); err != nil { + return rawBody, nil + } + if !forcedToolChoiceIncompatibleWithThinking(payload) { + return rawBody, nil + } + if !stripReasoningFields(payload) { + return rawBody, nil + } + out, err := sonic.Marshal(payload) + if err != nil { + return rawBody, err + } + return out, nil +} + +func stripReasoningFields(payload map[string]any) bool { + changed := false + for _, key := range reasoningPayloadKeys { + if _, ok := payload[key]; ok { + delete(payload, key) + changed = true + } + } + return changed +} + +func forcedToolChoiceIncompatibleWithThinking(payload map[string]any) bool { + tc, ok := payload["tool_choice"] + if !ok || tc == nil { + return false + } + switch v := tc.(type) { + case string: + return v == "required" + case map[string]any: + return true + default: + return false + } +} diff --git a/internal/openai/reasoning_payload_test.go b/internal/openai/reasoning_payload_test.go new file mode 100644 index 00000000..fddb7e66 --- /dev/null +++ b/internal/openai/reasoning_payload_test.go @@ -0,0 +1,120 @@ +package openai + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestStripReasoningFromChatCompletionBody(t *testing.T) { + in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`) + out, err := StripReasoningFromChatCompletionBody(in) + if err != nil { + t.Fatal(err) + } + s := string(out) + if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") { + t.Fatalf("expected reasoning fields stripped, got %s", s) + } + if !strings.Contains(s, `"model":"deepseek-chat"`) { + t.Fatalf("expected model preserved, got %s", s) + } + + plain := []byte(`{"model":"gpt-4o","messages":[]}`) + out2, err := StripReasoningFromChatCompletionBody(plain) + if err != nil { + t.Fatal(err) + } + if string(out2) != string(plain) { + t.Fatalf("expected unchanged payload, got %s", out2) + } +} + +func TestStripReasoningIfForcedToolChoice(t *testing.T) { + cases := []struct { + name string + in string + strip bool + contain string + }{ + { + name: "required strips thinking", + in: `{"model":"minimax","messages":[],"thinking":{"type":"enabled"},"tool_choice":"required","tools":[]}`, + strip: true, + }, + { + name: "object tool_choice strips thinking", + in: `{"model":"qwen","messages":[],"thinking":{"type":"enabled"},"tool_choice":{"type":"function","function":{"name":"respond"}}}`, + strip: true, + }, + { + name: "auto keeps thinking", + in: `{"model":"qwen","messages":[],"thinking":{"type":"enabled"},"tool_choice":"auto"}`, + strip: false, + contain: "thinking", + }, + { + name: "no tool_choice keeps thinking", + in: `{"model":"qwen","messages":[],"thinking":{"type":"enabled"}}`, + strip: false, + contain: "thinking", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := StripReasoningIfForcedToolChoice([]byte(tc.in)) + if err != nil { + t.Fatal(err) + } + s := string(out) + hasThinking := strings.Contains(s, "thinking") + if tc.strip && hasThinking { + t.Fatalf("expected thinking stripped, got %s", s) + } + if !tc.strip && tc.contain != "" && !strings.Contains(s, tc.contain) { + t.Fatalf("expected %q in %s", tc.contain, s) + } + if !tc.strip && string(out) != tc.in { + t.Fatalf("expected unchanged payload, got %s", s) + } + }) + } +} + +func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) { + var gotBody string + rt := &reasoningToolChoiceCompatRoundTripper{ + base: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(req.Body) + gotBody = string(b) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", strings.NewReader( + `{"model":"m","thinking":{"type":"enabled"},"tool_choice":"required","messages":[]}`, + )) + if err != nil { + t.Fatal(err) + } + _, err = rt.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if strings.Contains(gotBody, "thinking") { + t.Fatalf("expected thinking stripped in transit, got %s", gotBody) + } + if !strings.Contains(gotBody, `"tool_choice":"required"`) { + t.Fatalf("expected tool_choice preserved, got %s", gotBody) + } +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/internal/openai/reasoning_tool_choice_compat.go b/internal/openai/reasoning_tool_choice_compat.go new file mode 100644 index 00000000..ca4250c9 --- /dev/null +++ b/internal/openai/reasoning_tool_choice_compat.go @@ -0,0 +1,43 @@ +package openai + +import ( + "bytes" + "io" + "net/http" + "strconv" + "strings" +) + +// reasoningToolChoiceCompatRoundTripper strips thinking/reasoning fields from +// chat/completions requests that force tool_choice, which some gateways reject +// when thinking mode is enabled on the same request. +type reasoningToolChoiceCompatRoundTripper struct { + base http.RoundTripper +} + +func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if rt == nil || rt.base == nil || req == nil || req.Body == nil { + if rt != nil && rt.base != nil { + return rt.base.RoundTrip(req) + } + return http.DefaultTransport.RoundTrip(req) + } + if req.Method != http.MethodPost || !strings.HasSuffix(req.URL.Path, "/chat/completions") { + return rt.base.RoundTrip(req) + } + + body, err := io.ReadAll(req.Body) + _ = req.Body.Close() + if err != nil { + return nil, err + } + + patched, perr := StripReasoningIfForcedToolChoice(body) + if perr != nil { + patched = body + } + req.Body = io.NopCloser(bytes.NewReader(patched)) + req.ContentLength = int64(len(patched)) + req.Header.Set("Content-Length", strconv.Itoa(len(patched))) + return rt.base.RoundTrip(req) +} diff --git a/internal/reasoning/eino.go b/internal/reasoning/eino.go index b27c9f8a..d8fcd86f 100644 --- a/internal/reasoning/eino.go +++ b/internal/reasoning/eino.go @@ -26,6 +26,35 @@ const ( wireOutputConfig ) +// ApplyPlanExecutePlannerModelConfig configures the plan_execute planner/replanner +// ChatModel. Those Eino agents call WithToolChoice(Forced); several gateways reject +// thinking / reasoning fields on the same request (tool_choice required/object). +// Executor should keep the normal ApplyToEinoChatModelConfig path. +func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig) { + if cfg == nil || oa == nil { + return + } + offOA := *oa + offReasoning := oa.Reasoning + offReasoning.Mode = "off" + offOA.Reasoning = offReasoning + ApplyToEinoChatModelConfig(cfg, &offOA, nil) + clearReasoningFromChatModelConfig(cfg) +} + +func clearReasoningFromChatModelConfig(cfg *einoopenai.ChatModelConfig) { + if cfg == nil { + return + } + cfg.ReasoningEffort = "" + if cfg.ExtraFields != nil { + for _, key := range []string{"thinking", "reasoning_effort", "output_config", "reasoning"} { + delete(cfg.ExtraFields, key) + } + } + applyThinkingDisabled(cfg) +} + // 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) { diff --git a/internal/reasoning/eino_test.go b/internal/reasoning/eino_test.go index 1a9666e3..8db3121e 100644 --- a/internal/reasoning/eino_test.go +++ b/internal/reasoning/eino_test.go @@ -49,6 +49,30 @@ func TestApplyOpenAICompat_xhighExtraField(t *testing.T) { } } +func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testing.T) { + cfg := &einoopenai.ChatModelConfig{} + oa := &config.OpenAIConfig{ + BaseURL: "https://antchat.example.com/v1", + Model: "minimax-m3", + Reasoning: config.OpenAIReasoningConfig{ + Profile: "openai_compat", + Mode: "on", + Effort: "high", + }, + } + ApplyPlanExecutePlannerModelConfig(cfg, oa) + if cfg.ReasoningEffort != "" { + t.Fatalf("expected ReasoningEffort cleared, got %q", cfg.ReasoningEffort) + } + th, ok := cfg.ExtraFields["thinking"].(map[string]any) + if !ok || th["type"] != "disabled" { + t.Fatalf("expected thinking disabled, got %#v", cfg.ExtraFields) + } + if _, ok := cfg.ExtraFields["reasoning_effort"]; ok { + t.Fatalf("expected reasoning_effort stripped, got %#v", cfg.ExtraFields) + } +} + func TestApplyReasoningOff_disablesThinking(t *testing.T) { cfg := &einoopenai.ChatModelConfig{} oa := &config.OpenAIConfig{