fix(openai): preserve provider-specific reasoning disable intent (#366)

This commit is contained in:
Ed1s0nZ committed 2026-10-21 15:43:53 +08:00
1 parent be2c8c04e5
commit 5c5c5e199d
13 files changed
+566 -11

No files matched your search

+2
View File
@@ -8,6 +8,8 @@
### Fixed
- 修复已知支持 `none` 的官方 OpenAI 模型在关闭推理后仍使用服务端默认推理的问题(#366):按提供商、端点和模型发送并保留显式关闭参数,覆盖 Planner 和强制工具选择;保留 DeepSeek 与第三方兼容网关行为,并明确提示需要 Responses 的工具调用限制。
- 防止内置管理员失权:拒绝停用内置 `admin` 或移除其系统管理员角色,失败时保留原资料、权限和会话;界面同步禁用对应操作。
- 修复资产管理信息收集向 FOFA 默认请求不支持的 `province` 返回字段导致查询失败的问题,统一使用 `region`;兼容旧表单/API 字段别名,保留导入资产的省份,并修正情报字段预设为 `asn/org`。
+12
View File
@@ -76,6 +76,18 @@ ai:
The chat page reads saved channels into the “AI Channel” selector. A non-empty request `aiChannelId` selects a channel for that run/session without sending API credentials through the prompt path. Empty `aiChannelId` follows `ai.default_channel`.
### Disabling reasoning by provider and model
Use `reasoning.mode: off` (or **Reasoning → Off** in chat) to disable reasoning. The request format depends on the channel:
- On `https://api.openai.com/v1`, with an OpenAI-compatible provider and the `auto` or `openai_compat` profile, known models that support `none` send `reasoning_effort: "none"`. This covers GPT-5.5, GPT-5.6 (Sol/Terra/Luna and the `gpt-5.6` alias), GPT-6 Sol/Luna, and their dated snapshots. These models also accept `reasoning.effort: none` in configuration or the request. Planner requests retain explicit `none` through forced tool selection.
- DeepSeek channels using the `auto` or `deepseek_compat` profile retain `thinking: {type: disabled}` when off. An explicit profile takes precedence over automatic detection.
- Other models and third-party gateways retain the existing behavior: off omits reasoning fields. A gateway using an OpenAI model name is not automatically treated as the native OpenAI service.
For GPT-5.6 and GPT-6 Sol/Luna function tools on Chat Completions, choose **Off** explicitly; leaving effort unspecified uses the model's default reasoning and does not disable it. Requests combining these tools with reasoning return guidance to turn reasoning off instead of silently changing the requested effort. GPT-6.1 Sol and GPT-6 Astra require the Responses API for tools and do not support `none`; this Chat Completions backend reports that limitation and requires selecting a compatible model. Changing only `base_url` to a Responses URL does not adapt the protocol. See the [official reasoning guide](https://developers.openai.com/api/docs/guides/reasoning).
When `allow_client_reasoning: false`, chat/request reasoning overrides are ignored and the channel's settings apply.
Common Web UI operations:
- Add: click `+`, fill required fields, then save.
+12
View File
@@ -75,6 +75,18 @@ ai:
| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 |
| `reasoning` | 该通道的默认推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。 |
### 按提供商和模型关闭推理
使用 `reasoning.mode: off`,或在对话中选择“推理 → 关闭”。实际请求参数按通道区分:
- 官方 `https://api.openai.com/v1`、OpenAI 兼容提供商、`auto` 或 `openai_compat` profile 下,已知支持 `none` 的模型发送 `reasoning_effort: "none"`。覆盖 GPT-5.5、GPT-5.6(Sol/Terra/Luna 及 `gpt-5.6` 别名)、GPT-6 Sol/Luna 和对应日期快照;这些模型也接受配置或请求中的 `reasoning.effort: none`。Planner 的强制工具选择同样保留显式 `none`。
- DeepSeek 在 `auto` 或 `deepseek_compat` profile 下,关闭时仍发送 `thinking: {type: disabled}`。显式 profile 优先于自动识别。
- 其他模型和第三方兼容网关保持原有行为:关闭时省略推理字段。第三方网关使用 OpenAI 同名模型,不会被自动视为官方服务。
GPT-5.6 和 GPT-6 Sol/Luna 在 Chat Completions 中使用函数工具时,应明确选择“关闭”;不指定强度会采用模型默认推理,不等同于关闭。工具与推理不兼容时会提示关闭推理,不会静默改写用户选择的强度。GPT-6.1 Sol 和 GPT-6 Astra 不支持 `none`,工具调用必须使用 Responses;当前 Chat Completions 后端会明确提示该限制,需要选择兼容模型。仅把 `base_url` 改成 Responses URL 不能完成协议适配。详见 [OpenAI 官方推理说明](https://developers.openai.com/api/docs/guides/reasoning)。
`allow_client_reasoning: false` 时忽略对话或请求的推理覆盖,按通道设置执行。
对话页的“AI 通道”下拉框会读取已保存通道。请求体中的 `aiChannelId` 非空时仅对本次/本会话运行配置生效,不会把 API Key 发送给模型;为空时跟随 `ai.default_channel`。
常用操作:
+3 -3
View File
@@ -1040,16 +1040,16 @@ func (c *Config) NormalizeAIProviderProfiles() {
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
type OpenAIReasoningConfig struct {
// Mode: auto(默认)| on | off | default(与 auto 相同)。
// off 在 OpenAI/Claude profile 下省略推理字段;DeepSeek profile 下发送 thinking.type=disabled(其默认开启思考)。
// off 对已知支持 none 的官方 OpenAI 模型发送 reasoning_effort=none;DeepSeek profile 发送 thinking.type=disabled;其他服务保持省略推理字段。
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// Effort: low | medium | high | max | xhigh;max/xhigh 为不同网关最高档命名,原样下发、不互转。空表示不单独指定强度。
// Effort: low | medium | high | max | xhigh;已知支持 none 的官方 OpenAI 模型另接受 none(等同关闭)。空表示不单独指定强度。
Effort string `yaml:"effort,omitempty" json:"effort,omitempty"`
// AllowClientReasoning 为 false 时忽略请求体 reasoning;nil 或未设置等同于 true。
AllowClientReasoning *bool `yaml:"allow_client_reasoning,omitempty" json:"allow_client_reasoning,omitempty"`
// Profile: auto | deepseek_compat | openai_compat | output_config_effort
Profile string `yaml:"profile,omitempty" json:"profile,omitempty"`
// ExtraRequestFields 合并进 Chat Completions 根 JSON(管理员用;与自动字段同名时后者覆盖)。
// Mode=off 时会移除其中的推理控制字段,但保留其他扩展字段;DeepSeek profile 随后补充显式关闭开关。
// Mode=off 时移除其中的推理控制字段,保留其他扩展字段,再按提供商与已知模型能力补充显式关闭参数。
ExtraRequestFields map[string]interface{} `yaml:"extra_request_fields,omitempty" json:"extra_request_fields,omitempty"`
}
+64
View File
@@ -0,0 +1,64 @@
package config
import (
"net/url"
"strings"
"time"
)
// UsesOfficialOpenAIReasoning scopes model-specific rules to the native OpenAI
// service. An OpenAI-compatible provider or model name alone says nothing about
// a third-party gateway's capabilities. Explicit non-OpenAI profiles win.
func (c *OpenAIConfig) UsesOfficialOpenAIReasoning() bool {
if c == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(c.Provider)) {
case "", "openai", "openai_compatible":
default:
return false
}
switch c.Reasoning.ProfileEffective() {
case "auto", "openai", "openai_compat":
default:
return false
}
u, err := url.Parse(strings.TrimSpace(c.BaseURL))
return err == nil && u.Scheme == "https" && strings.EqualFold(u.Host, "api.openai.com") &&
strings.TrimRight(u.Path, "/") == "/v1" && u.RawQuery == "" && u.Fragment == "" && u.User == nil
}
// SupportsOpenAIReasoningNone deliberately lists known models, not future GPT
// versions, Codex models, or pro variants. See the official model/reasoning docs:
// https://developers.openai.com/api/docs/guides/reasoning
func (c *OpenAIConfig) SupportsOpenAIReasoningNone() bool {
return c.UsesOfficialOpenAIReasoning() && matchesOpenAIModel(c.Model,
"gpt-5.5", "gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
"gpt-6-sol", "gpt-6-luna")
}
// OpenAIChatToolsNeedNone identifies models whose Chat Completions function
// tools require reasoning to be explicitly disabled.
func (c *OpenAIConfig) OpenAIChatToolsNeedNone() bool {
return c.SupportsOpenAIReasoningNone() && !matchesOpenAIModel(c.Model, "gpt-5.5")
}
// OpenAIToolsRequireResponses identifies models for which disabling reasoning
// cannot make Chat Completions tool calling work.
func (c *OpenAIConfig) OpenAIToolsRequireResponses() bool {
return c.UsesOfficialOpenAIReasoning() && matchesOpenAIModel(c.Model, "gpt-6-astra", "gpt-6.1-sol")
}
func matchesOpenAIModel(model string, names ...string) bool {
for _, name := range names {
if model == name {
return true
}
if suffix, ok := strings.CutPrefix(model, name+"-"); ok {
if _, err := time.Parse("2006-01-02", suffix); err == nil {
return true
}
}
}
return false
}
@@ -0,0 +1,65 @@
package config
import "testing"
func TestOfficialOpenAIReasoningCapabilities(t *testing.T) {
for _, tc := range []struct {
model string
none, toolsNone, responses bool
}{
{"gpt-5.5", true, false, false},
{"gpt-5.5-2026-04-23", true, false, false},
{"gpt-5.6", true, true, false},
{"gpt-5.6-sol", true, true, false},
{"gpt-5.6-terra", true, true, false},
{"gpt-5.6-luna", true, true, false},
{"gpt-6-sol", true, true, false},
{"gpt-6-luna", true, true, false},
{"gpt-6.1-sol", false, false, true},
{"gpt-6-astra", false, false, true},
{"gpt-5-nano", false, false, false},
{"gpt-4o-mini", false, false, false},
{"gpt-5.5-pro", false, false, false},
{"gpt-5.6-sol-custom", false, false, false},
{"gpt-5.6-sol-2026-99-99", false, false, false},
{"gpt-6.2-sol", false, false, false},
{"deepseek-chat", false, false, false},
} {
t.Run(tc.model, func(t *testing.T) {
cfg := &OpenAIConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", Model: tc.model}
if cfg.SupportsOpenAIReasoningNone() != tc.none || cfg.OpenAIChatToolsNeedNone() != tc.toolsNone || cfg.OpenAIToolsRequireResponses() != tc.responses {
t.Fatalf("unexpected capabilities: none=%v toolsNone=%v responses=%v", cfg.SupportsOpenAIReasoningNone(), cfg.OpenAIChatToolsNeedNone(), cfg.OpenAIToolsRequireResponses())
}
})
}
}
func TestOpenAICapabilityServiceBoundary(t *testing.T) {
for _, tc := range []struct {
provider, url, profile string
want bool
}{
{"", "https://api.openai.com/v1", "", true},
{"openai_compatible", "https://api.openai.com/v1/", "openai_compat", true},
{"openai", "https://api.openai.com/v1", "deepseek_compat", false},
{"claude", "https://api.openai.com/v1", "openai_compat", false},
{"openai", "https://api.deepseek.com/v1", "openai_compat", false},
{"openai", "https://gateway.example/v1", "openai_compat", false},
{"openai", "https://api.openai.com.evil.example/v1", "", false},
{"openai", "https://api.openai.com/custom/v1", "", false},
{"openai", "https://api.openai.com/v1?gateway=1", "", false},
{"openai", "http://api.openai.com/v1", "", false},
{"openai", "", "", false},
} {
t.Run(tc.provider+tc.url+tc.profile, func(t *testing.T) {
cfg := &OpenAIConfig{Provider: tc.provider, BaseURL: tc.url, Model: "gpt-5.6-sol", Reasoning: OpenAIReasoningConfig{Profile: tc.profile}}
if cfg.SupportsOpenAIReasoningNone() != tc.want {
t.Fatalf("none=%v, want %v", cfg.SupportsOpenAIReasoningNone(), tc.want)
}
})
}
var cfg *OpenAIConfig
if cfg.SupportsOpenAIReasoningNone() || cfg.OpenAIToolsRequireResponses() {
t.Fatal("nil config has capabilities")
}
}
@@ -0,0 +1,162 @@
package multiagent
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/reasoning"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
type reasoningWireTransport func(*http.Request) (*http.Response, error)
func (f reasoningWireTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
// Exercise both production factories, real SDK serialization, planner setup,
// and the full transport chain. No requests leave the recording transport.
func TestReasoningProviderWireCompatibility(t *testing.T) {
for _, kind := range []string{"classic", "agentic"} {
for _, streaming := range []bool{false, true} {
for _, tc := range []struct {
name, url, model, profile, mode, effort string
client *reasoning.ClientIntent
planner bool
wantEffort, wantThinking, wantErr string
stripChoice bool
}{
{name: "openai_off", model: "gpt-5.6-sol", mode: "off", effort: "high", wantEffort: "none"},
{name: "openai_client_off", model: "gpt-5.6-sol", mode: "on", effort: "high", client: &reasoning.ClientIntent{Mode: "off"}, wantEffort: "none"},
{name: "openai_explicit_none", model: "gpt-5.6-sol", mode: "on", effort: "none", wantEffort: "none"},
{name: "openai_planner", model: "gpt-5.6-sol", mode: "on", effort: "high", planner: true, wantEffort: "none"},
{name: "openai_55_off", model: "gpt-5.5", mode: "off", wantEffort: "none"},
{name: "openai_6_sol_off", model: "gpt-6-sol", mode: "off", wantEffort: "none"},
{name: "openai_high_tools", model: "gpt-5.6-sol", mode: "on", effort: "high", wantErr: "set reasoning mode to off"},
{name: "openai_default_tools", model: "gpt-5.6-sol", mode: "auto", wantErr: "set reasoning mode to off"},
{name: "openai_61_tools", model: "gpt-6.1-sol", mode: "off", wantErr: "requires /v1/responses"},
{name: "openai_astra_planner", model: "gpt-6-astra", planner: true, wantErr: "requires /v1/responses"},
{name: "older_openai_off", model: "gpt-4o-mini", mode: "off"},
{name: "unknown_model_off", model: "gpt-6.2-sol", mode: "off"},
{name: "deepseek_off", url: "https://api.deepseek.com/v1", model: "deepseek-v4-flash", mode: "off", wantThinking: "disabled"},
{name: "deepseek_on", url: "https://api.deepseek.com/v1", model: "deepseek-v4-flash", mode: "on", effort: "high", wantEffort: "high", wantThinking: "enabled", stripChoice: true},
{name: "deepseek_planner", url: "https://api.deepseek.com/v1", model: "deepseek-v4-flash", mode: "on", planner: true, wantThinking: "disabled"},
{name: "explicit_deepseek_profile", url: "https://gateway.example/v1", model: "gpt-5.6-sol", profile: "deepseek_compat", mode: "off", wantThinking: "disabled"},
{name: "gateway_same_model_off", url: "https://gateway.example/v1", model: "gpt-5.6-sol", mode: "off"},
{name: "gateway_same_model_forced_on", url: "https://gateway.example/v1", model: "gpt-5.6-sol", mode: "on", effort: "high"},
{name: "gateway_planner", url: "https://gateway.example/v1", model: "gpt-5.6-sol", mode: "on", planner: true},
{name: "openai_profile_on_deepseek", url: "https://api.deepseek.com/v1", model: "deepseek-v4-flash", profile: "openai_compat", mode: "off"},
} {
name := kind + "/" + tc.name
if streaming {
name += "/stream"
}
t.Run(name, func(t *testing.T) {
baseURL := tc.url
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
oa := config.OpenAIConfig{Provider: "openai_compatible", APIKey: "local-test", BaseURL: baseURL, Model: tc.model,
Reasoning: config.OpenAIReasoningConfig{Mode: tc.mode, Effort: tc.effort, Profile: tc.profile,
ExtraRequestFields: map[string]interface{}{"vendor_option": true}}}
var body map[string]any
calls := 0
client := &http.Client{Transport: reasoningWireTransport(func(req *http.Request) (*http.Response, error) {
calls++
if req.URL.Path != "/v1/chat/completions" {
t.Errorf("unexpected endpoint: %s", req.URL.Path)
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
t.Fatal(err)
}
content := `{"id":"test","object":"chat.completion","created":1,"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`
contentType := "application/json"
if streaming {
contentType = "text/event-stream"
content = "data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
}
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(content)), Request: req}, nil
})}
ctx := context.Background()
mode := einoModelModeNormal
if tc.planner {
mode = einoModelModePlanner
}
var chat model.ToolCallingChatModel
var err error
if kind == "classic" {
chat, err = newEinoToolCallingChatModelFactory(client, tc.client, nil)(ctx, oa, mode)
} else {
var native model.AgenticModel
native, err = newEinoAgenticChatModelFactory(client, tc.client, nil)(ctx, oa, mode)
chat = newAgenticToolCallingChatModelAdapter(native)
}
if err != nil {
t.Fatal(err)
}
opts := []model.Option{model.WithTools([]*schema.ToolInfo{{Name: "lookup", Desc: "Look up a value", ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{})}}), model.WithToolChoice(schema.ToolChoiceForced)}
input := []*schema.Message{schema.UserMessage("hello")}
if streaming {
var stream *schema.StreamReader[*schema.Message]
stream, err = chat.Stream(ctx, input, opts...)
if err == nil {
defer stream.Close()
for {
_, recvErr := stream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
err = recvErr
break
}
}
}
} else {
_, err = chat.Generate(ctx, input, opts...)
}
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error=%v, want %q", err, tc.wantErr)
}
if calls != 0 {
t.Fatalf("invalid combination sent %d requests", calls)
}
if isEinoTransientRunError(err) {
t.Fatalf("configuration error considered transient: %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("calls=%d, want 1", calls)
}
gotEffort, _ := body["reasoning_effort"].(string)
if gotEffort != tc.wantEffort {
t.Fatalf("effort=%q, want %q; body=%#v", gotEffort, tc.wantEffort, body)
}
thinking, _ := body["thinking"].(map[string]any)
gotThinking, _ := thinking["type"].(string)
if gotThinking != tc.wantThinking {
t.Fatalf("thinking=%q, want %q", gotThinking, tc.wantThinking)
}
_, choice := body["tool_choice"]
if choice == tc.stripChoice {
t.Fatalf("tool_choice present=%v, strip=%v", choice, tc.stripChoice)
}
tools, _ := body["tools"].([]any)
if len(tools) != 1 || body["vendor_option"] != true {
t.Fatalf("lost tools or unrelated fields: %#v", body)
}
})
}
}
}
}
@@ -0,0 +1,52 @@
package openai
import (
"fmt"
"net/http"
"strings"
"cyberstrike-ai/internal/config"
"github.com/bytedance/sonic"
)
// patchNativeOpenAIReasoning runs before generic gateway cleanup. In
// particular, forced tool_choice must not erase an explicit none and restore
// the model's server-side default reasoning. Inspect the actual body model so
// per-call model overrides cannot inherit another model's capabilities.
func patchNativeOpenAIReasoning(req *http.Request, raw []byte, cfg *config.OpenAIConfig) ([]byte, bool, error) {
if !cfg.UsesOfficialOpenAIReasoning() || req.URL.Scheme != "https" || !strings.EqualFold(req.URL.Host, "api.openai.com") {
return raw, false, nil
}
var payload map[string]any
if err := sonic.Unmarshal(raw, &payload); err != nil {
return raw, false, nil
}
actual := *cfg
actual.Model, _ = payload["model"].(string)
hasFunctionTools := false
if tools, ok := payload["tools"].([]any); ok {
for _, tool := range tools {
if definition, ok := tool.(map[string]any); ok && definition["type"] == "function" {
hasFunctionTools = true
}
}
}
if actual.OpenAIToolsRequireResponses() && hasFunctionTools {
return nil, true, fmt.Errorf("OpenAI model %q requires /v1/responses for function tools and does not support reasoning_effort=none; this backend uses /v1/chat/completions: select a compatible model", actual.Model)
}
if !actual.SupportsOpenAIReasoningNone() {
return raw, false, nil
}
effort, _ := payload["reasoning_effort"].(string)
if actual.OpenAIChatToolsNeedNone() && hasFunctionTools && effort != "none" {
return nil, true, fmt.Errorf("OpenAI model %q requires reasoning_effort=none for function tools in /v1/chat/completions: set reasoning mode to off; reasoning with function tools requires /v1/responses", actual.Model)
}
if effort != "none" || !forcedToolChoiceIncompatibleWithThinking(payload) {
return raw, false, nil
}
stripReasoningFields(payload)
payload["reasoning_effort"] = "none"
out, err := sonic.Marshal(payload)
return out, true, err
}
@@ -0,0 +1,68 @@
package openai
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"cyberstrike-ai/internal/config"
)
func TestNativeReasoningActualModelAndToolChoice(t *testing.T) {
for _, tc := range []struct {
name, url, model, effort, choice, wantEffort, wantErr string
}{
{"auto_none", "https://api.openai.com/v1", "gpt-5.6-sol", "none", "auto", "none", ""},
{"auto_high_rejected", "https://api.openai.com/v1", "gpt-5.6-sol", "high", "auto", "", "set reasoning mode to off"},
{"object_none", "https://api.openai.com/v1", "gpt-5.6-sol", "none", `{"type":"function","function":{"name":"lookup"}}`, "none", ""},
{"override_requires_responses", "https://api.openai.com/v1", "gpt-6.1-sol", "none", "auto", "", "requires /v1/responses"},
{"override_older_model", "https://api.openai.com/v1", "gpt-4o-mini", "high", "auto", "high", ""},
{"gateway_auto_high_unchanged", "https://gateway.example/v1", "gpt-5.6-sol", "high", "auto", "high", ""},
{"gateway_responses_model_unchanged", "https://gateway.example/v1", "gpt-6.1-sol", "high", "auto", "high", ""},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.OpenAIConfig{Provider: "openai", BaseURL: tc.url, Model: "gpt-5.6-sol"}
choice := any(tc.choice)
if strings.HasPrefix(tc.choice, "{") {
choice = json.RawMessage(tc.choice)
}
body, err := json.Marshal(map[string]any{"model": tc.model, "reasoning_effort": tc.effort, "tool_choice": choice,
"messages": []any{}, "tools": []any{map[string]any{"type": "function", "function": map[string]any{"name": "lookup"}}}})
if err != nil {
t.Fatal(err)
}
calls := 0
rt := &reasoningToolChoiceCompatRoundTripper{cfg: cfg, base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
calls++
var got map[string]any
if err := json.NewDecoder(req.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got["reasoning_effort"] != tc.wantEffort {
t.Fatalf("effort=%v, want %q", got["reasoning_effort"], tc.wantEffort)
}
if got["model"] != tc.model {
t.Fatalf("request model changed: %#v", got)
}
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader("{}"))}, nil
})}
req, err := http.NewRequest(http.MethodPost, tc.url+"/chat/completions", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
resp, err := rt.RoundTrip(req)
if resp != nil {
resp.Body.Close()
}
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || calls != 0 {
t.Fatalf("err=%v calls=%d", err, calls)
}
} else if err != nil || calls != 1 {
t.Fatalf("err=%v calls=%d", err, calls)
}
})
}
}
@@ -35,12 +35,17 @@ func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*
return nil, err
}
patched := body
patched, handled, err := patchNativeOpenAIReasoning(req, body, rt.cfg)
if err != nil {
return nil, err
}
var perr error
if isDeepSeekToolChoiceCompatProfile(rt.cfg) {
patched, perr = StripToolChoiceForThinkingMode(body)
} else {
patched, perr = StripReasoningIfForcedToolChoice(body)
if !handled {
if isDeepSeekToolChoiceCompatProfile(rt.cfg) {
patched, perr = StripToolChoiceForThinkingMode(body)
} else {
patched, perr = StripReasoningIfForcedToolChoice(body)
}
}
if perr != nil {
patched = body
+6 -3
View File
@@ -36,6 +36,7 @@ func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *con
}
mergeExtraRequestFields(cfg, oa.Reasoning.ExtraRequestFields)
clearReasoningFromChatModelConfig(cfg)
applyOpenAIReasoningDisabled(cfg, oa)
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
// DeepSeek enables thinking by default, so omission would not actually
// disable it for the planner's forced tool-choice requests.
@@ -78,7 +79,7 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
}
sr := &oa.Reasoning
allowClient := sr.AllowClientReasoningEffective()
mode := effectiveMode(sr, client, allowClient)
mode := effectiveModeForModel(oa, client)
// Admin-defined root fields are independent of the selected reasoning wire
// profile. Merge them first so mode=off can remove only reasoning controls
@@ -86,6 +87,7 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
mergeExtraRequestFields(cfg, sr.ExtraRequestFields)
if mode == "off" {
clearReasoningFromChatModelConfig(cfg)
applyOpenAIReasoningDisabled(cfg, oa)
// Strict OpenAI endpoints reject unknown `thinking` fields, whereas the
// DeepSeek API enables thinking by default and requires an explicit
// thinking.type=disabled switch. The configured profile is authoritative;
@@ -128,7 +130,7 @@ func AgenticOpenAIExtraFields(oa *config.OpenAIConfig, client *ClientIntent) map
}
sr := &oa.Reasoning
allowClient := sr.AllowClientReasoningEffective()
mode := effectiveMode(sr, client, allowClient)
mode := effectiveModeForModel(oa, client)
fields := cloneExtraRequestFields(sr.ExtraRequestFields)
if mode == "off" {
clearReasoningExtraFields(fields)
@@ -138,7 +140,7 @@ func AgenticOpenAIExtraFields(oa *config.OpenAIConfig, client *ClientIntent) map
}
fields["thinking"] = map[string]any{"type": "disabled"}
}
return fields
return openAIReasoningDisabledFields(fields, oa)
}
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") ||
strings.EqualFold(strings.TrimSpace(oa.Provider), "anthropic") {
@@ -194,6 +196,7 @@ func AgenticOpenAIPlannerExtraFields(oa *config.OpenAIConfig) map[string]any {
}
fields := cloneExtraRequestFields(oa.Reasoning.ExtraRequestFields)
clearReasoningExtraFields(fields)
fields = openAIReasoningDisabledFields(fields, oa)
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
if fields == nil {
fields = make(map[string]any)
+44
View File
@@ -0,0 +1,44 @@
package reasoning
import (
"strings"
"cyberstrike-ai/internal/config"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
)
// effectiveModeForModel accepts effort=none only for known native OpenAI
// models. Other providers keep their existing effort normalization and fallback.
func effectiveModeForModel(oa *config.OpenAIConfig, client *ClientIntent) string {
sr := &oa.Reasoning
allowClient := sr.AllowClientReasoningEffective()
mode := effectiveMode(sr, client, allowClient)
if !oa.SupportsOpenAIReasoningNone() {
return mode
}
effort := sr.Effort
if allowClient && client != nil && (normalizeEffort(client.Effort) != "" || strings.EqualFold(strings.TrimSpace(client.Effort), "none")) {
effort = client.Effort
}
if strings.EqualFold(strings.TrimSpace(effort), "none") {
return "off"
}
return mode
}
func applyOpenAIReasoningDisabled(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig) {
if oa.SupportsOpenAIReasoningNone() {
cfg.ReasoningEffort = einoopenai.ReasoningEffortLevel("none")
}
}
func openAIReasoningDisabledFields(fields map[string]any, oa *config.OpenAIConfig) map[string]any {
if oa.SupportsOpenAIReasoningNone() {
if fields == nil {
fields = make(map[string]any)
}
fields["reasoning_effort"] = "none"
}
return fields
}
+66
View File
@@ -0,0 +1,66 @@
package reasoning
import (
"testing"
"cyberstrike-ai/internal/config"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
)
func TestOpenAINoneIntentPrecedence(t *testing.T) {
no := false
for _, tc := range []struct {
name, mode, effort string
client *ClientIntent
allow *bool
want string
}{
{"off", "off", "high", nil, nil, "none"},
{"server_none", "on", "none", nil, nil, "none"},
{"client_off", "on", "high", &ClientIntent{Mode: "off"}, nil, "none"},
{"client_none", "on", "high", &ClientIntent{Effort: " NONE "}, nil, "none"},
{"client_high_overrides_server_none", "on", "none", &ClientIntent{Effort: "high"}, nil, "high"},
{"client_none_disallowed", "on", "high", &ClientIntent{Effort: "none"}, &no, "high"},
{"client_off_disallowed", "on", "high", &ClientIntent{Mode: "off"}, &no, "high"},
{"server_none_client_disallowed", "on", "none", &ClientIntent{Effort: "high"}, &no, "none"},
{"auto_unspecified", "auto", "", nil, nil, ""},
} {
t.Run(tc.name, func(t *testing.T) {
oa := &config.OpenAIConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", Model: "gpt-5.6-sol",
Reasoning: config.OpenAIReasoningConfig{Mode: tc.mode, Effort: tc.effort, AllowClientReasoning: tc.allow}}
cfg := &einoopenai.ChatModelConfig{}
ApplyToEinoChatModelConfig(cfg, oa, tc.client)
if string(cfg.ReasoningEffort) != tc.want {
t.Fatalf("classic effort=%q, want %q", cfg.ReasoningEffort, tc.want)
}
fields := AgenticOpenAIExtraFields(oa, tc.client)
got, _ := fields["reasoning_effort"].(string)
if got != tc.want {
t.Fatalf("agentic effort=%q, want %q", got, tc.want)
}
})
}
}
func TestNoneDoesNotChangeOtherProviders(t *testing.T) {
for _, oa := range []config.OpenAIConfig{
{BaseURL: "https://api.deepseek.com/v1", Model: "deepseek-v4-flash"},
{BaseURL: "https://gateway.example/v1", Model: "gpt-5.6-sol"},
{BaseURL: "https://api.openai.com/v1", Model: "gpt-4o-mini"},
{BaseURL: "https://api.openai.com/v1", Model: "gpt-6.1-sol"},
} {
t.Run(oa.BaseURL+oa.Model, func(t *testing.T) {
oa.Reasoning = config.OpenAIReasoningConfig{Mode: "on", Effort: "high"}
cfg := &einoopenai.ChatModelConfig{}
baseline := &einoopenai.ChatModelConfig{}
ApplyToEinoChatModelConfig(cfg, &oa, &ClientIntent{Effort: "none"})
ApplyToEinoChatModelConfig(baseline, &oa, nil)
if cfg.ReasoningEffort != baseline.ReasoningEffort {
t.Fatalf("none changed unsupported model: %#v", cfg)
}
if cfg.ExtraFields["reasoning_effort"] != baseline.ExtraFields["reasoning_effort"] {
t.Fatalf("none changed provider effort: %#v", cfg.ExtraFields)
}
})
}
}