Fix audit model thinking compatibility

This commit is contained in:
Ed1s0nZ committed 2026-10-30 16:11:07 +08:00
1 parent 5affb4df1b
commit bc0b288d40
8 files changed
+172 -2

No files matched your search

+2
View File
@@ -10,6 +10,8 @@
### Fixed
- 修复审计 Agent 向不支持 thinking 字段的兼容网关发起请求失败的问题;支持独立兼容 profile,并按模型和地址识别默认行为。
- 修复已知支持 `none` 的官方 OpenAI 模型在关闭推理后仍使用服务端默认推理的问题(#366):按提供商、端点和模型发送并保留显式关闭参数,覆盖 Planner 和强制工具选择;保留 DeepSeek 与第三方兼容网关行为,并明确提示需要 Responses 的工具调用限制。
- 防止内置管理员失权:拒绝停用内置 `admin` 或移除其系统管理员角色,失败时保留原资料、权限和会话;界面同步禁用对应操作。
+2
View File
@@ -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
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
+6
View File
@@ -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)
}
@@ -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)
}
})
}
}
+1
View File
@@ -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())
+3 -2
View File
@@ -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 {
+23
View File
@@ -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")
}
@@ -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)
}
}