diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e31674..c533c78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- 修复审计 Agent 向不支持 thinking 字段的兼容网关发起请求失败的问题;支持独立兼容 profile,并按模型和地址识别默认行为。 + - 修复已知支持 `none` 的官方 OpenAI 模型在关闭推理后仍使用服务端默认推理的问题(#366):按提供商、端点和模型发送并保留显式关闭参数,覆盖 Planner 和强制工具选择;保留 DeepSeek 与第三方兼容网关行为,并明确提示需要 Responses 的工具调用限制。 - 防止内置管理员失权:拒绝停用内置 `admin` 或移除其系统管理员角色,失败时保留原资料、权限和会话;界面同步禁用对应操作。 diff --git a/config.example.yaml b/config.example.yaml index a25f1882..9e53e2e0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -201,6 +201,8 @@ hitl: base_url: "" # openai 后端留空跟随主模型;typesafe 后端留空使用 https://api.typesafe.ai api_key: "" # openai 后端留空跟随主模型;typesafe 后端填写 TypeSafe API Key model: "" # openai 后端建议填小模型;typesafe 后端留空使用 jev-latest + reasoning: + profile: "" # auto / deepseek_compat / openai_compat;仅控制审计请求的 thinking 字段 # 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理) retention_days: 90 # 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 [] diff --git a/internal/config/config.go b/internal/config/config.go index ac4d35b1..776bd4e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1230,6 +1230,12 @@ func (h HitlConfig) TypeSafeConfigEffective() (baseURL, apiKey, model string) { func (h HitlConfig) AuditModelEffective(main OpenAIConfig) OpenAIConfig { out := main am := h.AuditModel + if strings.TrimSpace(am.Provider) != "" || strings.TrimSpace(am.BaseURL) != "" || strings.TrimSpace(am.Model) != "" { + out.Reasoning.Profile = "" + } + if strings.TrimSpace(am.Reasoning.Profile) != "" { + out.Reasoning.Profile = am.Reasoning.Profile + } if strings.TrimSpace(am.Provider) != "" { out.Provider = strings.TrimSpace(am.Provider) } diff --git a/internal/config/hitl_audit_profile_test.go b/internal/config/hitl_audit_profile_test.go new file mode 100644 index 00000000..64c72d72 --- /dev/null +++ b/internal/config/hitl_audit_profile_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestAuditModelProfileInheritance(t *testing.T) { + main := OpenAIConfig{Reasoning: OpenAIReasoningConfig{Profile: "deepseek_compat"}} + for _, tc := range []struct { + name string + audit OpenAIConfig + want string + }{ + {"inherit", OpenAIConfig{}, "deepseek_compat"}, + {"key alone inherits", OpenAIConfig{APIKey: "other"}, "deepseek_compat"}, + {"model isolates", OpenAIConfig{Model: "glm"}, ""}, + {"url isolates", OpenAIConfig{BaseURL: "https://example.com"}, ""}, + {"provider isolates", OpenAIConfig{Provider: "openai"}, ""}, + {"explicit overrides", OpenAIConfig{Reasoning: OpenAIReasoningConfig{Profile: "openai_compat"}}, "openai_compat"}, + {"explicit auto overrides", OpenAIConfig{Reasoning: OpenAIReasoningConfig{Profile: "auto"}}, "auto"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := (HitlConfig{AuditModel: tc.audit}).AuditModelEffective(main).Reasoning.Profile + if got != tc.want { + t.Fatalf("profile=%q want %q", got, tc.want) + } + }) + } +} diff --git a/internal/handler/config.go b/internal/handler/config.go index d36c8e55..f963f47a 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -2359,6 +2359,7 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) { setStringInMap(auditModelNode, "base_url", cfg.AuditModel.BaseURL) setStringInMap(auditModelNode, "api_key", cfg.AuditModel.APIKey) setStringInMap(auditModelNode, "model", cfg.AuditModel.Model) + setStringInMap(ensureMap(auditModelNode, "reasoning"), "profile", cfg.AuditModel.Reasoning.Profile) // flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数 setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist) setStringInMap(hitlNode, "default_mode", cfg.EffectiveDefaultMode()) diff --git a/internal/handler/hitl_audit_agent.go b/internal/handler/hitl_audit_agent.go index 4da67c75..c7db6119 100644 --- a/internal/handler/hitl_audit_agent.go +++ b/internal/handler/hitl_audit_agent.go @@ -51,8 +51,9 @@ func (h *AgentHandler) auditAgentReview(ctx context.Context, hitlMode, toolName }, "temperature": 0.1, "max_completion_tokens": 1024, - // 审计裁决需要结构化 JSON;关闭 thinking 避免 Qwen 等把正文放进 reasoning_content 导致解析失败。 - "thinking": map[string]interface{}{"type": "disabled"}, + } + if auditDisableThinking(llmCfg) { + requestBody["thinking"] = map[string]interface{}{"type": "disabled"} } var apiResponse struct { diff --git a/internal/handler/hitl_audit_reasoning.go b/internal/handler/hitl_audit_reasoning.go new file mode 100644 index 00000000..df0e8e8d --- /dev/null +++ b/internal/handler/hitl_audit_reasoning.go @@ -0,0 +1,23 @@ +package handler + +import ( + "strings" + + "cyberstrike-ai/internal/config" +) + +// auditDisableThinking selects only the audit request's provider-specific field. +func auditDisableThinking(cfg config.OpenAIConfig) bool { + switch cfg.Reasoning.ProfileEffective() { + case "deepseek", "deepseek_compat": + return true + case "auto": + default: + return false + } + identity := strings.ToLower(cfg.Model + " " + cfg.BaseURL) + if strings.Contains(identity, "glm") { + return false + } + return strings.Contains(identity, "deepseek") || strings.Contains(identity, "qwen") +} diff --git a/internal/handler/hitl_audit_reasoning_test.go b/internal/handler/hitl_audit_reasoning_test.go new file mode 100644 index 00000000..5f08f0b9 --- /dev/null +++ b/internal/handler/hitl_audit_reasoning_test.go @@ -0,0 +1,108 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "cyberstrike-ai/internal/config" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +func TestAuditAgentRequestCompatibility(t *testing.T) { + cases := []struct { + name, model, profile, mainProfile string + wantThinking, fail bool + }{ + {name: "glm", model: "tencent:glm-5.3"}, + {name: "deepseek", model: "deepseek-chat", wantThinking: true}, + {name: "qwen", model: "qwen-plus", wantThinking: true}, + {name: "unknown", model: "other"}, + {name: "explicit deepseek", model: "glm-5", profile: "deepseek_compat", wantThinking: true}, + {name: "explicit openai", model: "deepseek-chat", profile: "openai_compat"}, + {name: "isolated main profile", model: "glm-5", mainProfile: "deepseek_compat"}, + {name: "failure is rejected", model: "glm-5", fail: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + w.WriteHeader(400) + return + } + thinking, present := body["thinking"] + if present != tc.wantThinking { + t.Errorf("thinking present=%v, want %v", present, tc.wantThinking) + } + if present { + v, ok := thinking.(map[string]any) + if !ok || v["type"] != "disabled" { + t.Errorf("thinking=%v", thinking) + } + } + if body["max_completion_tokens"] != float64(1024) || body["temperature"] != 0.1 { + t.Errorf("unexpected request limits: %v", body) + } + if tc.fail { + w.WriteHeader(400) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"decision\":\"approve\"}"}}]}`)) + })) + defer server.Close() + h := &AgentHandler{logger: zap.NewNop(), config: &config.Config{OpenAI: config.OpenAIConfig{APIKey: "test", Model: "main", Reasoning: config.OpenAIReasoningConfig{Profile: tc.mainProfile}}, Hitl: config.HitlConfig{AuditModel: config.OpenAIConfig{BaseURL: server.URL, Model: tc.model, Reasoning: config.OpenAIReasoningConfig{Profile: tc.profile}}}}} + decision := h.auditAgentReview(context.Background(), "approval", "test", nil) + want := "approve" + if tc.fail { + want = "reject" + } + if decision.Decision != want || calls != 1 { + t.Fatalf("decision=%+v calls=%d", decision, calls) + } + }) + } +} + +func TestAuditThinkingDetectionAddressAndPrecedence(t *testing.T) { + for _, tc := range []struct { + model, url, profile string + want bool + }{ + {"other", "https://deepseek.example/v1", "", true}, + {"other", "https://qwen.example/v1", "", true}, + {"glm-5", "https://deepseek.example/v1", "", false}, + {"deepseek", "https://example/v1", "openai_compat", false}, + } { + got := auditDisableThinking(config.OpenAIConfig{Model: tc.model, BaseURL: tc.url, Reasoning: config.OpenAIReasoningConfig{Profile: tc.profile}}) + if got != tc.want { + t.Errorf("model=%s url=%s profile=%s got %v", tc.model, tc.url, tc.profile, got) + } + } +} + +func TestAuditProfileConfigPersistence(t *testing.T) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte("hitl:\n audit_model:\n reasoning:\n profile: openai_compat\n"), &doc); err != nil { + t.Fatal(err) + } + updateHitlConfig(&doc, config.HitlConfig{AuditModel: config.OpenAIConfig{Reasoning: config.OpenAIReasoningConfig{Profile: "deepseek_compat"}}}) + raw, err := yaml.Marshal(&doc) + if err != nil { + t.Fatal(err) + } + var decoded config.Config + if err := yaml.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if got := decoded.Hitl.AuditModel.Reasoning.Profile; got != "deepseek_compat" { + t.Fatalf("profile=%q", got) + } +}