diff --git a/backend/internal/application/channel/model_catalog_test.go b/backend/internal/application/channel/model_catalog_test.go index 093f07fb..47d77955 100644 --- a/backend/internal/application/channel/model_catalog_test.go +++ b/backend/internal/application/channel/model_catalog_test.go @@ -242,6 +242,103 @@ func TestReasoningContentPassbackRequiredForDeepSeekChatCompletions(t *testing.T } } +// Moonshot、智谱、小米 MiMo 的思考模型同样要求历史 assistant 消息原样携带 reasoning_content, +// 覆盖显式 vendor 与仅凭模型名推断两种入口,并反向断言未纳入白名单的厂商保持关闭。 +func TestReasoningContentPassbackRequiredForAdditionalChatCompletionsVendors(t *testing.T) { + required := []struct { + name string + candidates []string + }{ + {"moonshot vendor", []string{"moonshot", "kimi-k3"}}, + {"kimi alias", []string{"kimi", "kimi-k2.7-code"}}, + {"kimi by model name", []string{"", "kimi-k3"}}, + {"moonshot by model name", []string{"", "moonshot-v1-128k"}}, + {"zhipu vendor", []string{"zhipu", "glm-4.6"}}, + {"glm alias", []string{"glm", "glm-4.6"}}, + {"glm by model name", []string{"", "glm-4.6"}}, + {"chatglm by model name", []string{"", "chatglm-6b"}}, + {"xiaomi vendor", []string{"xiaomi", "mimo-v2.5-pro"}}, + {"mimo by model name", []string{"", "mimo-v2.5-pro"}}, + {"mimo omni by model name", []string{"", "mimo-v2-omni"}}, + {"mimo namespaced model name", []string{"", "xiaomi/mimo-v2.5-pro"}}, + {"alibaba vendor", []string{"alibaba", "qwen3.6-plus"}}, + {"qwen alias", []string{"qwen", "qwen-max"}}, + {"qwq by model name", []string{"", "qwq-32b"}}, + {"qvq by model name", []string{"", "qvq-max"}}, + {"tongyi by model name", []string{"", "tongyi-deepresearch"}}, + {"minimax vendor", []string{"minimax", "minimax-m2"}}, + {"abab by model name", []string{"", "abab-6.5s"}}, + {"hailuo by model name", []string{"", "hailuo-02"}}, + } + for _, item := range required { + if !reasoningContentPassbackRequired(llm.AdapterOpenAIChatCompletions, item.candidates...) { + t.Fatalf("%s: expected Chat Completions route to require reasoning_content passback", item.name) + } + } + + // 非 Chat Completions 协议不受影响。 + if reasoningContentPassbackRequired(llm.AdapterOpenAIResponses, "moonshot", "kimi-k3") { + t.Fatal("expected Responses route to skip reasoning_content passback for moonshot") + } + if reasoningContentPassbackRequired(llm.AdapterOpenAIResponses, "zhipu", "glm-4.6") { + t.Fatal("expected Responses route to skip reasoning_content passback for zhipu") + } + + // 未列入白名单的厂商保持关闭,避免向不接受该字段的上游发送多余入参。 + for _, item := range []struct { + name string + candidates []string + }{ + {"bytedance", []string{"bytedance", "doubao-seed-1-6"}}, + {"doubao by model name", []string{"", "doubao-1-5-thinking-pro"}}, + {"tencent", []string{"tencent", "hunyuan-turbos"}}, + {"anthropic", []string{"anthropic", "claude-opus-4-8"}}, + } { + if reasoningContentPassbackRequired(llm.AdapterOpenAIChatCompletions, item.candidates...) { + t.Fatalf("%s: expected vendor outside the passback allowlist to skip reasoning_content", item.name) + } + } +} + +// 阿里 Qwen 只回传字段无效,必须同时下发 preserve_thinking;其余厂商不应收到该私有入参, +// OpenRouter 更要拦住——它有自己的 reasoning 字段与参数校验。 +func TestReasoningPassbackRequestOptionsOnlyForAlibabaChatCompletions(t *testing.T) { + got := reasoningPassbackRequestOptions(llm.AdapterOpenAIChatCompletions, "alibaba", "qwen3.6-plus") + if len(got) != 1 || got["preserve_thinking"] != true { + t.Fatalf("expected preserve_thinking for alibaba chat completions, got %#v", got) + } + if byName := reasoningPassbackRequestOptions(llm.AdapterOpenAIChatCompletions, "", "qwq-32b"); byName["preserve_thinking"] != true { + t.Fatalf("expected model-name inference to require preserve_thinking, got %#v", byName) + } + + // 返回值必须是副本,调用方改动不能污染包级变量。 + got["preserve_thinking"] = false + got["injected"] = true + again := reasoningPassbackRequestOptions(llm.AdapterOpenAIChatCompletions, "alibaba", "qwen3.6-plus") + if len(again) != 1 || again["preserve_thinking"] != true { + t.Fatalf("package-level options were mutated by caller: %#v", again) + } + + for _, item := range []struct { + name string + protocol string + candidates []string + }{ + {"openrouter alibaba", llm.AdapterOpenRouterChat, []string{"alibaba", "qwen/qwen3-max"}}, + {"responses alibaba", llm.AdapterOpenAIResponses, []string{"alibaba", "qwen3.6-plus"}}, + {"minimax", llm.AdapterOpenAIChatCompletions, []string{"minimax", "minimax-m2"}}, + {"deepseek", llm.AdapterOpenAIChatCompletions, []string{"deepseek", "deepseek-v4-flash-free"}}, + {"moonshot", llm.AdapterOpenAIChatCompletions, []string{"moonshot", "kimi-k3"}}, + {"zhipu", llm.AdapterOpenAIChatCompletions, []string{"zhipu", "glm-4.6"}}, + {"xiaomi", llm.AdapterOpenAIChatCompletions, []string{"xiaomi", "mimo-v2.5-pro"}}, + {"vendor outside allowlist", llm.AdapterOpenAIChatCompletions, []string{"anthropic", "claude-opus-4-8"}}, + } { + if options := reasoningPassbackRequestOptions(item.protocol, item.candidates...); options != nil { + t.Fatalf("%s: expected no vendor request options, got %#v", item.name, options) + } + } +} + func TestNormalizeModelIconSeparatesVendorAndModelFamily(t *testing.T) { tests := map[string]struct { vendor string diff --git a/backend/internal/application/channel/service.go b/backend/internal/application/channel/service.go index 4a0e91c9..8f80437e 100644 --- a/backend/internal/application/channel/service.go +++ b/backend/internal/application/channel/service.go @@ -137,6 +137,7 @@ type ResolvedRoute struct { ModelSystemPrompt string UpstreamModel string ReasoningContentPassback bool + ReasoningPassbackRequestOptions map[string]interface{} UpstreamCbFailureThreshold int UpstreamCbModelThreshold int UpstreamCbThresholdLogic string diff --git a/backend/internal/application/channel/service_routing.go b/backend/internal/application/channel/service_routing.go index 0ddc1bcd..54dbfb04 100644 --- a/backend/internal/application/channel/service_routing.go +++ b/backend/internal/application/channel/service_routing.go @@ -363,6 +363,7 @@ func buildResolvedRoute(row repository.ChannelUpstreamRouteRow, apiKey string) * ModelSystemPrompt: strings.TrimSpace(row.ModelSystemPrompt), UpstreamModel: strings.TrimSpace(row.UpstreamModelName), ReasoningContentPassback: reasoningContentPassbackRequired(row.Protocol, row.ModelVendor, row.PlatformModelName, row.UpstreamModelName, row.UpstreamName), + ReasoningPassbackRequestOptions: reasoningPassbackRequestOptions(row.Protocol, row.ModelVendor, row.PlatformModelName, row.UpstreamModelName, row.UpstreamName), UpstreamCbFailureThreshold: row.UpstreamCbFailureThreshold, UpstreamCbModelThreshold: row.UpstreamCbModelThreshold, UpstreamCbThresholdLogic: row.UpstreamCbThresholdLogic, diff --git a/backend/internal/application/channel/service_view_normalization.go b/backend/internal/application/channel/service_view_normalization.go index f9caf4ac..bac3e222 100644 --- a/backend/internal/application/channel/service_view_normalization.go +++ b/backend/internal/application/channel/service_view_normalization.go @@ -403,17 +403,65 @@ func normalizeUpstreamModelVendor(raw string, candidates ...string) string { return "unknown" } +// reasoningContentPassbackVendors 列出 Chat Completions 协议下要求回传 reasoning_content 的厂商。 +// 这些厂商的思考模型都要求历史 assistant 消息原样携带 reasoning_content: +// DeepSeek 与小米 MiMo 在历史含工具调用时缺失该字段会直接返回 400;Moonshot 同样返回 400; +// 智谱默认 clear_thinking=false 即保留式思考,官方明确裁剪或改写历史推理比完全不传更糟; +// 阿里 Qwen 需配合 preserve_thinking 入参(见 reasoningPassbackVendorRequestOptions)才会读取历史推理; +// MiniMax 自 M2 起 chat template 同时接受 content 内联 与 reasoning_content, +// 而应用层已把 统一收敛进 reasoning_content,无需额外解析。 +// +// 仍未纳入:bytedance(Doubao) 由服务端自行判断历史思维链是否参与推理,缺乏一手证据表明回传必需。 +var reasoningContentPassbackVendors = map[string]bool{ + "deepseek": true, + "moonshot": true, + "zhipu": true, + "xiaomi": true, + "alibaba": true, + "minimax": true, +} + +// reasoningPassbackVendorRequestOptions 列出「仅回传字段无效、必须同时下发」的厂商私有请求入参。 +// 阿里百炼默认忽略 messages 里的历史 reasoning_content,须显式传 preserve_thinking=true +// 才会把历史推理拼接进下一轮输入;不传则只是白白付出推理 token 且不报错。 +var reasoningPassbackVendorRequestOptions = map[string]map[string]interface{}{ + "alibaba": {"preserve_thinking": true}, +} + func reasoningContentPassbackRequired(protocol string, candidates ...string) bool { switch llm.NormalizeAdapter(protocol) { case llm.AdapterOpenRouterChat: return true case llm.AdapterOpenAIChatCompletions: - return detectModelVendor(candidates...) == "deepseek" + return reasoningContentPassbackVendors[detectModelVendor(candidates...)] default: return false } } +// reasoningPassbackRequestOptions 返回本路由回传生效时需附加的厂商私有请求入参副本。 +// 仅在原生 Chat Completions 协议下生效:OpenRouter 有自己的 reasoning 字段与参数校验, +// 转发厂商私有顶层入参会被判为未知参数。返回副本避免调用方污染包级变量。 +func reasoningPassbackRequestOptions(protocol string, candidates ...string) map[string]interface{} { + if llm.NormalizeAdapter(protocol) != llm.AdapterOpenAIChatCompletions { + return nil + } + vendor := detectModelVendor(candidates...) + // 与回传白名单联动,避免两张表漂移:回传都没开启时不应下发配套入参。 + if !reasoningContentPassbackVendors[vendor] { + return nil + } + required := reasoningPassbackVendorRequestOptions[vendor] + if len(required) == 0 { + return nil + } + options := make(map[string]interface{}, len(required)) + for key, value := range required { + options[key] = value + } + return options +} + func detectModelVendor(candidates ...string) string { fallback := "" for _, candidate := range candidates { diff --git a/backend/internal/application/conversation/model_option_policy.go b/backend/internal/application/conversation/model_option_policy.go index cfefe654..83e46308 100644 --- a/backend/internal/application/conversation/model_option_policy.go +++ b/backend/internal/application/conversation/model_option_policy.go @@ -773,3 +773,65 @@ func cloneModelOptionValue(value interface{}) interface{} { return typed } } + +// shouldApplyReasoningPassbackRequestOptions 判断本轮是否需要下发厂商私有的回传配套入参。 +// +// 三个条件缺一不可: +// - 回传实际生效(路由能力 AND 用户设置),否则等于付费读历史推理却没有历史推理可读; +// - 该路由确有配套入参要求; +// - 本轮真实发送的历史里已存在非空推理。这些入参只影响「历史」思维链的处理方式, +// 首轮或非思考模型下发它没有收益,且能规避自建后端把未知顶层字段判为非法入参。 +func shouldApplyReasoningPassbackRequestOptions( + passbackEnabled bool, + required map[string]interface{}, + messages []llm.Message, +) bool { + if !passbackEnabled || len(required) == 0 { + return false + } + return promptCarriesAssistantReasoning(messages) +} + +// promptCarriesAssistantReasoning 判断本轮真实发送的历史里是否已有非空 assistant 推理内容。 +func promptCarriesAssistantReasoning(messages []llm.Message) bool { + for _, item := range messages { + if item.Role == "assistant" && strings.TrimSpace(item.ReasoningContent) != "" { + return true + } + } + return false +} + +// withReasoningPassbackRequestOptions 补齐厂商要求的回传配套入参。 +// +// 该入参属于协议正确性而非用户偏好,因此绕过管理员选项白名单——白名单收窄时不应让回传 +// 静默退化成「传了字段但模型不读」。但用户或管理员显式声明过的值一律不覆盖:除了已过滤 +// 结果,还需回看 rawOptions 与模型能力 defaultOptions,因为白名单模式会把未放行的键丢掉, +// 只看 options 会把管理员刻意设的 false 覆盖成 true。 +func withReasoningPassbackRequestOptions( + options map[string]interface{}, + required map[string]interface{}, + rawOptions map[string]interface{}, + capabilitiesJSON string, +) map[string]interface{} { + if len(required) == 0 { + return options + } + defaults := modelCapabilityDefaultOptions(capabilitiesJSON) + for key, value := range required { + if _, ok := options[key]; ok { + continue + } + if _, ok := rawOptions[key]; ok { + continue + } + if _, ok := defaults[key]; ok { + continue + } + if options == nil { + options = make(map[string]interface{}, len(required)) + } + options[key] = value + } + return options +} diff --git a/backend/internal/application/conversation/model_option_policy_test.go b/backend/internal/application/conversation/model_option_policy_test.go index 8f0cd976..edca9206 100644 --- a/backend/internal/application/conversation/model_option_policy_test.go +++ b/backend/internal/application/conversation/model_option_policy_test.go @@ -3,6 +3,7 @@ package conversation import ( "testing" + domainconversation "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" ) @@ -1037,3 +1038,139 @@ func TestFilterModelOptionsXAIImageAllowsImageParams(t *testing.T) { } } } + +func TestPromptCarriesAssistantReasoning(t *testing.T) { + cases := map[string]struct { + messages []llm.Message + want bool + }{ + "assistant with reasoning": { + messages: []llm.Message{ + {Role: "user", Content: "q"}, + {Role: "assistant", Content: "a", ReasoningContent: "thinking"}, + }, + want: true, + }, + "assistant reasoning is whitespace": { + messages: []llm.Message{{Role: "assistant", Content: "a", ReasoningContent: " \n "}}, + want: false, + }, + "reasoning on user role is ignored": { + messages: []llm.Message{{Role: "user", Content: "q", ReasoningContent: "leaked"}}, + want: false, + }, + "no history": {messages: nil, want: false}, + } + for name, item := range cases { + if got := promptCarriesAssistantReasoning(item.messages); got != item.want { + t.Fatalf("%s: promptCarriesAssistantReasoning() = %v, want %v", name, got, item.want) + } + } +} + +// 首轮没有历史推理、或用户关掉回传时都不应下发厂商私有入参,否则等于白付推理 token, +// 还可能让把未知顶层字段判为非法入参的自建后端直接报错。 +func TestShouldApplyReasoningPassbackRequestOptions(t *testing.T) { + required := map[string]interface{}{"preserve_thinking": true} + withReasoning := []llm.Message{{Role: "assistant", Content: "a", ReasoningContent: "thinking"}} + withoutReasoning := []llm.Message{{Role: "user", Content: "q"}} + + if !shouldApplyReasoningPassbackRequestOptions(true, required, withReasoning) { + t.Fatal("expected injection when passback is on and history carries reasoning") + } + if shouldApplyReasoningPassbackRequestOptions(false, required, withReasoning) { + t.Fatal("expected no injection when passback is disabled") + } + if shouldApplyReasoningPassbackRequestOptions(true, nil, withReasoning) { + t.Fatal("expected no injection when the route requires no vendor options") + } + if shouldApplyReasoningPassbackRequestOptions(true, required, withoutReasoning) { + t.Fatal("expected no injection on a first turn without historical reasoning") + } + + // 厂商判定是按 vendor 而非按模型的:detectModelVendor 会把 wanx / qwen-vl / qwen2.5 + // 等非思考模型一并归到 alibaba,路由层因此也标记它们「需要 preserve_thinking」。 + // 这些模型不产 reasoning_content,历史推理守卫是唯一防线——它必须挡住, + // 否则会向不认识该入参的自建后端(vLLM/Ollama 等)发送未知顶层字段。 + nonThinkingHistory := []llm.Message{ + {Role: "user", Content: "q1"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "q2"}, + } + if shouldApplyReasoningPassbackRequestOptions(true, required, nonThinkingHistory) { + t.Fatal("expected no injection for a non-thinking model that never emits reasoning") + } +} + +func TestWithReasoningPassbackRequestOptions(t *testing.T) { + required := map[string]interface{}{"preserve_thinking": true} + + got := withReasoningPassbackRequestOptions( + map[string]interface{}{"temperature": 0.7}, required, nil, "") + if got["preserve_thinking"] != true || got["temperature"] != 0.7 { + t.Fatalf("expected injection alongside existing options, got %#v", got) + } + + // 策略模式 disabled 时过滤结果为 nil,需要新建 map 而不是 panic。 + if fromNil := withReasoningPassbackRequestOptions(nil, required, nil, ""); fromNil["preserve_thinking"] != true { + t.Fatalf("expected a new map to be allocated, got %#v", fromNil) + } + + if noop := withReasoningPassbackRequestOptions(nil, nil, nil, ""); noop != nil { + t.Fatalf("expected untouched nil when nothing is required, got %#v", noop) + } + + // 用户显式设的值不被覆盖——包括已被白名单丢掉、只存在于原始入参里的那种。 + kept := withReasoningPassbackRequestOptions( + map[string]interface{}{"preserve_thinking": false}, required, nil, "") + if kept["preserve_thinking"] != false { + t.Fatalf("expected explicit user value to survive, got %#v", kept) + } + rawOptions := map[string]interface{}{"preserve_thinking": false} + dropped := withReasoningPassbackRequestOptions( + map[string]interface{}{}, required, rawOptions, "") + if _, exists := dropped["preserve_thinking"]; exists { + t.Fatalf("expected allowlist-dropped user value to block injection, got %#v", dropped) + } + + // 管理员在模型能力里设的默认值同样是显式意图。 + capabilities := `{"defaultOptions":{"preserve_thinking":false}}` + fromCapabilities := withReasoningPassbackRequestOptions( + map[string]interface{}{}, required, nil, capabilities) + if _, exists := fromCapabilities["preserve_thinking"]; exists { + t.Fatalf("expected capability default to block injection, got %#v", fromCapabilities) + } + + // 入参不得被就地修改。 + if len(required) != 1 || required["preserve_thinking"] != true { + t.Fatalf("required map was mutated: %#v", required) + } + if len(rawOptions) != 1 || rawOptions["preserve_thinking"] != false { + t.Fatalf("raw options were mutated: %#v", rawOptions) + } +} + +// 守卫扫描的是真实发往上游的 llmMessages。若历史推理在 historyMessagesFromDomain → +// cloneLLMMessages 这段链路上被丢掉,守卫会恒为 false,功能静默失效且无任何报错—— +// 正是 #529 的失效形态。这里锁死该链路。 +func TestReasoningPassbackGuardSeesHistoryFromDomain(t *testing.T) { + domainMessages := []domainconversation.Message{ + {Role: "user", Content: "q1"}, + {Role: "assistant", Content: "a1", ReasoningContent: "historical thinking"}, + {Role: "user", Content: "q2"}, + } + + history := historyMessagesFromDomain(domainMessages, historyMessageOptions{ReasoningContentPassback: true}) + if !promptCarriesAssistantReasoning(history) { + t.Fatal("guard cannot see reasoning right after historyMessagesFromDomain") + } + if !promptCarriesAssistantReasoning(cloneLLMMessages(history)) { + t.Fatal("cloneLLMMessages dropped reasoning before the guard runs") + } + + // 回传关闭时历史不带推理,守卫必须为 false,避免下发无用入参。 + disabled := historyMessagesFromDomain(domainMessages, historyMessageOptions{ReasoningContentPassback: false}) + if promptCarriesAssistantReasoning(disabled) { + t.Fatal("guard should stay false when passback is disabled") + } +} diff --git a/backend/internal/application/conversation/service_message_completion.go b/backend/internal/application/conversation/service_message_completion.go index 47a925ad..48e40ea5 100644 --- a/backend/internal/application/conversation/service_message_completion.go +++ b/backend/internal/application/conversation/service_message_completion.go @@ -342,6 +342,7 @@ func (s *Service) persistInterruptedMessageGeneration(ctx context.Context, input input.AssistantMessage.ID, repository.AssistantMessageCompletionUpdate{ Content: input.AssistantText, + ReasoningContent: strings.TrimSpace(input.AssistantReasoningText), InputTokens: interruptedCompletionInputTokens(input, metrics), OutputTokens: metrics.OutputTokens, CacheReadTokens: interruptedCompletionCacheReadTokens(input, metrics), @@ -502,6 +503,7 @@ func applyInterruptedMessageGenerationState(input persistInterruptedMessageGener } input.AssistantMessage.Content = input.AssistantText + input.AssistantMessage.ReasoningContent = strings.TrimSpace(input.AssistantReasoningText) if input.ReuseUserMessage { input.AssistantMessage.InputTokens = metrics.InputTokens input.AssistantMessage.CacheReadTokens = metrics.CacheReadTokens diff --git a/backend/internal/application/conversation/service_message_completion_test.go b/backend/internal/application/conversation/service_message_completion_test.go index 279fb3be..575411ab 100644 --- a/backend/internal/application/conversation/service_message_completion_test.go +++ b/backend/internal/application/conversation/service_message_completion_test.go @@ -160,3 +160,24 @@ func TestCanceledGenerationRecoveredUsageRetainsEarlierEstimatedInput(t *testing t.Fatalf("usage source = %q, want mixed", source) } } + +// 中断的生成同样要保住已产出的推理内容:落库更新是无条件覆盖, +// 若不带上 ReasoningContent 就会把该轮推理抹成空字符串, +// 而 interrupted 状态的 assistant 消息仍会进入后续轮次的上下文。 +func TestInterruptedGenerationRetainsReasoningContent(t *testing.T) { + assistant := &model.Message{ReasoningContent: "stale"} + input := persistInterruptedMessageGenerationInput{ + UserMessage: &model.Message{}, + AssistantMessage: assistant, + AssistantText: "部分可见回复", + AssistantReasoningText: " 中断前已产出的思考内容 ", + Error: ErrMessageGenerationCanceled, + StartedAt: time.Now(), + } + + applyInterruptedMessageGenerationState(input, resolveInterruptedMessageGenerationMetrics(input)) + + if assistant.ReasoningContent != "中断前已产出的思考内容" { + t.Fatalf("expected trimmed reasoning to be retained, got %q", assistant.ReasoningContent) + } +} diff --git a/backend/internal/application/conversation/service_message_send.go b/backend/internal/application/conversation/service_message_send.go index 02398d4d..ddedcfca 100644 --- a/backend/internal/application/conversation/service_message_send.go +++ b/backend/internal/application/conversation/service_message_send.go @@ -755,6 +755,18 @@ func (s *Service) sendMessageInternal( DeniedPathsJSON: cfg.ModelOptionDeniedPaths, ModelCapabilitiesJSON: route.ModelCapabilitiesJSON, }) + if shouldApplyReasoningPassbackRequestOptions( + reasoningContentPassback, + route.ReasoningPassbackRequestOptions, + llmMessages, + ) { + filteredOptions = withReasoningPassbackRequestOptions( + filteredOptions, + route.ReasoningPassbackRequestOptions, + input.Options, + route.ModelCapabilitiesJSON, + ) + } generateInput := llm.GenerateInput{ RequestID: strings.TrimSpace(input.RequestID), ConversationID: input.ConversationID, diff --git a/backend/internal/application/conversation/service_share.go b/backend/internal/application/conversation/service_share.go index 99f2671d..b87596a2 100644 --- a/backend/internal/application/conversation/service_share.go +++ b/backend/internal/application/conversation/service_share.go @@ -518,6 +518,7 @@ func (s *Service) cloneSharedMessage( Role: strings.TrimSpace(source.Role), ContentType: contentType, Content: source.Content, + ReasoningContent: source.ReasoningContent, BranchReason: branchReason, SourceMessageID: sourceMessageID, TokenUsage: source.TokenUsage, diff --git a/backend/internal/application/conversation/service_share_test.go b/backend/internal/application/conversation/service_share_test.go index c5c9cdb1..bf6cb2ce 100644 --- a/backend/internal/application/conversation/service_share_test.go +++ b/backend/internal/application/conversation/service_share_test.go @@ -1,10 +1,12 @@ package conversation import ( + "context" "reflect" "testing" model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" ) func TestSharedMessagesIncludeFileUsesSnapshotAttachments(t *testing.T) { @@ -99,3 +101,46 @@ func TestNormalizeMessagePublicIDsDeduplicatesAndKeepsOrder(t *testing.T) { t.Fatalf("normalized ids mismatch: got %v, want %v", got, want) } } + +// 克隆共享会话时逐字段手写赋值,漏字段的后果与祖先链 CTE 漏列相同: +// 克隆出的会话可继续对话,历史 assistant 消息却没有推理内容,回传形同虚设。 +type cloneSharedMessageRepositoryStub struct { + repository.ConversationRepository + created []model.Message +} + +func (s *cloneSharedMessageRepositoryStub) CreateMessage(_ context.Context, message *model.Message) error { + message.ID = uint(len(s.created) + 1) + s.created = append(s.created, *message) + return nil +} + +func TestCloneSharedMessagePreservesReasoningContent(t *testing.T) { + repo := &cloneSharedMessageRepositoryStub{} + service := &Service{repo: repo} + + source := model.Message{ + PublicID: "a1", + Role: "assistant", + ContentType: "text", + Content: "答复", + ReasoningContent: "历史推理内容", + ReasoningTokens: 125, + Status: "success", + } + + cloned, err := service.cloneSharedMessage(context.Background(), 1, 2, source, "run_clone", map[string]uint{}) + if err != nil { + t.Fatalf("cloneSharedMessage() error = %v", err) + } + if cloned.ReasoningContent != "历史推理内容" { + t.Fatalf("cloned reasoning content = %q, want preserved", cloned.ReasoningContent) + } + // reasoning_tokens 一直被复制,若 reasoning_content 丢失会造成行内自相矛盾。 + if cloned.ReasoningTokens != 125 { + t.Fatalf("cloned reasoning tokens = %d, want 125", cloned.ReasoningTokens) + } + if len(repo.created) != 1 || repo.created[0].ReasoningContent != "历史推理内容" { + t.Fatalf("persisted row lost reasoning content: %#v", repo.created) + } +} diff --git a/backend/internal/infra/llm/model_caps_test.go b/backend/internal/infra/llm/model_caps_test.go index 3d382812..4767b493 100644 --- a/backend/internal/infra/llm/model_caps_test.go +++ b/backend/internal/infra/llm/model_caps_test.go @@ -26,4 +26,3 @@ func TestEffectiveContextBudgetFromCapabilitiesUsesConfiguredWindow(t *testing.T t.Fatalf("expected budget %d, got %d", want, got) } } - diff --git a/backend/internal/infra/llm/request_params_test.go b/backend/internal/infra/llm/request_params_test.go index 81bf8aaa..7ef617cc 100644 --- a/backend/internal/infra/llm/request_params_test.go +++ b/backend/internal/infra/llm/request_params_test.go @@ -1135,3 +1135,35 @@ func TestBuildGeminiRequestBodyAllowsNestedGenerationConfig(t *testing.T) { t.Fatalf("expected protected systemInstruction to be omitted, got %#v", payload["systemInstruction"]) } } + +// preserve_thinking 是阿里百炼的私有顶层入参,由会话层按厂商自动补发。 +// 这里确认它原样落到请求体顶层,且不会被 stream_options 的复制逻辑顺带吞进去。 +func TestBuildOpenAIChatCompletionsPassesPreserveThinking(t *testing.T) { + payload := mustBuildRequestBody(t, AdapterOpenAIChatCompletions, "qwen3.6-plus", EndpointChatCompletions, GenerateInput{ + Messages: []Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi", ReasoningContent: "thinking"}, + {Role: "user", Content: "again"}, + }, + Options: map[string]interface{}{"preserve_thinking": true}, + }, true) + + if payload["preserve_thinking"] != true { + t.Fatalf("expected preserve_thinking at the top level, got %#v", payload["preserve_thinking"]) + } + streamOptions, ok := payload["stream_options"].(map[string]interface{}) + if !ok { + t.Fatalf("expected stream_options to remain a map, got %#v", payload["stream_options"]) + } + if _, leaked := streamOptions["preserve_thinking"]; leaked { + t.Fatalf("vendor option leaked into stream_options: %#v", streamOptions) + } + + messages, ok := payload["messages"].([]map[string]interface{}) + if !ok || len(messages) != 3 { + t.Fatalf("unexpected messages payload: %#v", payload["messages"]) + } + if messages[1]["reasoning_content"] != "thinking" { + t.Fatalf("expected assistant reasoning passback, got %#v", messages[1]) + } +} diff --git a/backend/internal/infra/persistence/models/chat.go b/backend/internal/infra/persistence/models/chat.go index 409dc66d..2b97559b 100644 --- a/backend/internal/infra/persistence/models/chat.go +++ b/backend/internal/infra/persistence/models/chat.go @@ -121,7 +121,7 @@ type Message struct { Status string `gorm:"size:32;not null;default:'';index:idx_chat_messages_status;comment:消息处理状态"` ErrorCode string `gorm:"size:64;not null;default:'';comment:错误码"` ErrorMessage string `gorm:"size:255;not null;default:'';comment:错误信息"` - IsCompacted bool `gorm:"not null;default:false;index:idx_chat_messages_is_compacted;comment:是否已被压缩(压缩后不纳入祖先链)"` + IsCompacted bool `gorm:"not null;default:false;index:idx_chat_messages_is_compacted;comment:预留未使用(祖先链未按此过滤,无读写方)"` EditedAt *time.Time `gorm:"index:idx_chat_messages_edited_at;comment:用户编辑时间"` ParentPublicID string `gorm:"-"` SourcePublicID string `gorm:"-"` diff --git a/backend/internal/infra/persistence/postgres/conversation/repository.go b/backend/internal/infra/persistence/postgres/conversation/repository.go index 07afc7be..2a12fb80 100644 --- a/backend/internal/infra/persistence/postgres/conversation/repository.go +++ b/backend/internal/infra/persistence/postgres/conversation/repository.go @@ -1982,8 +1982,11 @@ func (r *Repo) ListMessageAncestors(ctx context.Context, conversationID uint, le } // WITH RECURSIVE:从叶节点沿 parent_message_id 向上递归,_depth 用于限制深度。 - // 外层 SELECT 显式列出所有 DB 列,排除 CTE 内部的 _depth 辅助列, - // 避免 GORM Scan 遇到未知字段。deleted_at IS NULL 保持软删除语义。 + // 外层用 SELECT * 取全部列:GORM Scan 按列名映射并忽略未匹配的列,_depth 会被自然丢弃, + // 因此无需手写列清单(手写清单曾漏掉 reasoning_content 导致推理回传失效)。 + // deleted_at IS NULL 保持软删除语义。 + // 递归项约束 m.conversation_id:parent_message_id 上没有外键,「父消息同会话」仅靠 + // 应用层保证,一旦被破坏,跨会话内容会进入 prompt 并被烤进压缩摘要反复重放。 const cteSQL = ` WITH RECURSIVE ancestors AS ( SELECT *, 1 AS _depth @@ -1995,19 +1998,14 @@ WITH RECURSIVE ancestors AS ( INNER JOIN ancestors a ON m.id = a.parent_message_id WHERE a.parent_message_id IS NOT NULL AND a._depth < ? + AND m.conversation_id = ? AND m.deleted_at IS NULL ) -SELECT id, conversation_id, user_id, public_id, parent_message_id, run_id, - role, content_type, content, branch_reason, source_message_id, - token_usage, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, - latency_ms, billed_currency, billed_nanousd, pricing_snapshot, - status, error_code, error_message, is_compacted, edited_at, - created_at, updated_at, deleted_at -FROM ancestors +SELECT * FROM ancestors ORDER BY id ASC` path := make([]models.Message, 0, maxDepth) - if err := r.db.WithContext(ctx).Raw(cteSQL, leafMessageID, conversationID, maxDepth).Scan(&path).Error; err != nil { + if err := r.db.WithContext(ctx).Raw(cteSQL, leafMessageID, conversationID, maxDepth, conversationID).Scan(&path).Error; err != nil { return nil, translateError(err) } @@ -2117,13 +2115,7 @@ WITH RECURSIVE ancestors AS ( AND m.conversation_id = ? AND m.deleted_at IS NULL ) -SELECT id, conversation_id, user_id, public_id, parent_message_id, run_id, - role, content_type, content, branch_reason, source_message_id, - token_usage, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, - latency_ms, billed_currency, billed_nanousd, pricing_snapshot, - status, error_code, error_message, is_compacted, edited_at, - created_at, updated_at, deleted_at -FROM ancestors +SELECT * FROM ancestors ORDER BY id ASC` path := make([]models.Message, 0, maxDepth) diff --git a/backend/internal/infra/persistence/postgres/conversation/repository_test.go b/backend/internal/infra/persistence/postgres/conversation/repository_test.go index e986e177..20cafc02 100644 --- a/backend/internal/infra/persistence/postgres/conversation/repository_test.go +++ b/backend/internal/infra/persistence/postgres/conversation/repository_test.go @@ -311,6 +311,187 @@ func TestListMessageAncestorsUntilReportsMissingBoundary(t *testing.T) { } } +// 祖先链走的是手写 CTE,与 GetMessageByID 的常规 GORM 查询是两条取数路径。 +// 这里逐字段比对两者结果,确保 CTE 不会丢列——曾因漏掉 reasoning_content 导致推理回传失效。 +// 注意覆盖边界:比对的是 domain.Message,因此只能守住会映射进领域模型的列; +// 未进入领域模型的列(如 is_compacted)不在此测试范围内。 +func TestListMessageAncestorsMatchesFullColumnLoad(t *testing.T) { + db := openConversationRepositoryTestDB(t) + repo := NewRepo(db) + ctx := context.Background() + + conversation := model.Conversation{ + UserID: 1, + PublicID: "conv_ancestors_columns", + Title: "ancestors columns", + LabelsJSON: "[]", + SessionKey: "session_ancestors_columns", + Status: "active", + } + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + root := model.Message{ + ConversationID: conversation.ID, + UserID: 1, + PublicID: "msg_columns_root", + Role: "user", + ContentType: "text", + Content: "root", + BranchReason: "default", + Status: "success", + } + if err := db.Create(&root).Error; err != nil { + t.Fatalf("create root message: %v", err) + } + + editedAt := time.Now().UTC().Truncate(time.Second) + sourceID := root.ID + // 所有可空/可选列都填非零值,任何一列被 CTE 丢弃都会在比对中暴露。 + leaf := model.Message{ + ConversationID: conversation.ID, + UserID: 1, + PublicID: "msg_columns_leaf", + ParentMessageID: &root.ID, + RunID: "run_columns", + Role: "assistant", + ContentType: "text", + Content: "leaf", + ReasoningContent: "historical reasoning", + BranchReason: "retry", + SourceMessageID: &sourceID, + TokenUsage: 321, + InputTokens: 111, + OutputTokens: 222, + CacheReadTokens: 33, + CacheWriteTokens: 44, + ReasoningTokens: 125, + LatencyMS: 987, + BilledCurrency: "USD", + BilledNanousd: 654, + PricingSnapshot: `{"in":1}`, + Status: "success", + ErrorCode: "none", + ErrorMessage: "no error", + IsCompacted: true, + EditedAt: &editedAt, + } + if err := db.Create(&leaf).Error; err != nil { + t.Fatalf("create leaf message: %v", err) + } + + want, err := repo.GetMessageByID(ctx, conversation.ID, leaf.ID) + if err != nil { + t.Fatalf("GetMessageByID() error = %v", err) + } + if want.ReasoningContent == "" { + t.Fatal("baseline load lost reasoning content") + } + + ancestors, err := repo.ListMessageAncestors(ctx, conversation.ID, leaf.ID, 10) + if err != nil { + t.Fatalf("ListMessageAncestors() error = %v", err) + } + if len(ancestors) != 2 { + t.Fatalf("expected root and leaf, got %d", len(ancestors)) + } + if !reflect.DeepEqual(ancestors[1], *want) { + t.Fatalf("ListMessageAncestors dropped columns:\n cte = %#v\nfull = %#v", ancestors[1], *want) + } + + until, found, err := repo.ListMessageAncestorsUntil(ctx, conversation.ID, leaf.ID, root.ID, 10) + if err != nil { + t.Fatalf("ListMessageAncestorsUntil() error = %v", err) + } + if !found { + t.Fatal("expected boundary to be found") + } + if len(until) != 2 { + t.Fatalf("expected root and leaf, got %d", len(until)) + } + if !reflect.DeepEqual(until[1], *want) { + t.Fatalf("ListMessageAncestorsUntil dropped columns:\n cte = %#v\nfull = %#v", until[1], *want) + } +} + +// 祖先链加载必须保留 reasoning_content,否则「回传推理上下文」在后续轮次拿不到历史推理。 +func TestListMessageAncestorsPreservesReasoningContent(t *testing.T) { + db := openConversationRepositoryTestDB(t) + repo := NewRepo(db) + ctx := context.Background() + + conversation := model.Conversation{ + UserID: 1, + PublicID: "conv_ancestors_reasoning", + Title: "ancestors reasoning", + LabelsJSON: "[]", + SessionKey: "session_ancestors_reasoning", + Status: "active", + } + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + var parentID *uint + messages := make([]model.Message, 0, 4) + for index := 1; index <= 4; index++ { + role := "user" + reasoning := "" + if index%2 == 0 { + role = "assistant" + reasoning = fmt.Sprintf("reasoning %d", index) + } + message := model.Message{ + ConversationID: conversation.ID, + UserID: 1, + PublicID: fmt.Sprintf("msg_reasoning_%d", index), + ParentMessageID: parentID, + Role: role, + ContentType: "text", + Content: fmt.Sprintf("message %d", index), + ReasoningContent: reasoning, + BranchReason: "default", + Status: "success", + } + if err := db.Create(&message).Error; err != nil { + t.Fatalf("create message %d: %v", index, err) + } + messages = append(messages, message) + nextParentID := message.ID + parentID = &nextParentID + } + + leafID := messages[len(messages)-1].ID + assertReasoning := func(t *testing.T, method string, got []domainconversation.Message) { + t.Helper() + if len(got) != len(messages) { + t.Fatalf("%s: expected %d ancestors, got %d", method, len(messages), len(got)) + } + for index, item := range got { + want := messages[index].ReasoningContent + if item.ReasoningContent != want { + t.Fatalf("%s: ancestor %d reasoning content = %q, want %q", method, index, item.ReasoningContent, want) + } + } + } + + ancestors, err := repo.ListMessageAncestors(ctx, conversation.ID, leafID, 10) + if err != nil { + t.Fatalf("ListMessageAncestors() error = %v", err) + } + assertReasoning(t, "ListMessageAncestors", ancestors) + + until, found, err := repo.ListMessageAncestorsUntil(ctx, conversation.ID, leafID, messages[0].ID, 10) + if err != nil { + t.Fatalf("ListMessageAncestorsUntil() error = %v", err) + } + if !found { + t.Fatal("expected boundary to be found") + } + assertReasoning(t, "ListMessageAncestorsUntil", until) +} + func TestUpdateAssistantMessageCompletionPersistsReasoningContent(t *testing.T) { db := openConversationRepositoryTestDB(t) repo := NewRepo(db) @@ -845,3 +1026,61 @@ func openConversationRepositoryTestDB(t *testing.T) *gorm.DB { } return db } + +// parent_message_id 上没有外键,「父消息同会话」只靠应用层保证。这里绕过应用层直接写入 +// 一条跨会话的父指针,确认递归查询不会走出当前会话——否则外部内容会进入 prompt 并被 +// 烤进压缩摘要反复重放。ListMessageAncestorsUntil 早已有此约束,两者需保持一致。 +func TestListMessageAncestorsStopsAtConversationBoundary(t *testing.T) { + db := openConversationRepositoryTestDB(t) + repo := NewRepo(db) + ctx := context.Background() + + makeConversation := func(publicID string) model.Conversation { + conversation := model.Conversation{ + UserID: 1, PublicID: publicID, Title: publicID, + LabelsJSON: "[]", SessionKey: "session_" + publicID, Status: "active", + } + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation %s: %v", publicID, err) + } + return conversation + } + foreign := makeConversation("conv_foreign") + own := makeConversation("conv_own") + + // 另一个会话中的消息,内容不应被泄漏到本会话的祖先链里。 + foreignMessage := model.Message{ + ConversationID: foreign.ID, UserID: 1, PublicID: "msg_foreign", + Role: "assistant", ContentType: "text", Content: "FOREIGN_SECRET", + ReasoningContent: "FOREIGN_REASONING", BranchReason: "default", Status: "success", + } + if err := db.Create(&foreignMessage).Error; err != nil { + t.Fatalf("create foreign message: %v", err) + } + + leaf := model.Message{ + ConversationID: own.ID, UserID: 1, PublicID: "msg_own_leaf", + ParentMessageID: &foreignMessage.ID, + Role: "user", ContentType: "text", Content: "own leaf", + BranchReason: "default", Status: "success", + } + if err := db.Create(&leaf).Error; err != nil { + t.Fatalf("create leaf: %v", err) + } + + got, err := repo.ListMessageAncestors(ctx, own.ID, leaf.ID, 10) + if err != nil { + t.Fatalf("ListMessageAncestors() error = %v", err) + } + for _, item := range got { + if item.ConversationID != own.ID { + t.Fatalf("ancestor walked into conversation %d: %#v", item.ConversationID, item) + } + if strings.Contains(item.Content, "FOREIGN_SECRET") { + t.Fatalf("foreign content leaked into ancestor chain: %#v", item) + } + } + if len(got) != 1 || got[0].PublicID != "msg_own_leaf" { + t.Fatalf("expected only the in-conversation leaf, got %#v", got) + } +}