From 220ee9dbfb9f5b7ef0241a1b6f7d7cf03f401d48 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:32:52 -0300 Subject: [PATCH] fix: normalize reasoning model families (#15612) ### What problem does this PR solve? Closes #15611. RAGFlow's fallback reasoning parser only recognized the exact model family `qwen3`. For provider-prefixed Qwen model names such as SiliconFlow's `qwen/qwen3-8b`, the derived model class can be `qwen/qwen3`, so inline `...` content was not split from the visible answer when `reasoning_content` was absent. This PR normalizes model-family detection before fallback reasoning extraction, keeps the parser nil-safe, and adds focused tests for Qwen3 variants plus Gitee and SiliconFlow chat responses. It also makes SiliconFlow propagate `ChatConfig.Thinking` into the chat request body, matching the existing Gitee behavior, so Qwen thinking mode is actually enabled when requested. ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue) - [x] Refactoring ### Validation - `/root/go/bin/gofmt -l internal/entity/models/common.go internal/entity/models/common_test.go internal/entity/models/reasoning_family_provider_test.go internal/entity/models/siliconflow.go` - `git diff --check` - `/root/go/bin/go test ./internal/entity/models -run 'Test(NormalizeModelFamily|GetThinkingAndAnswer|GiteeChatExtractsQwenThinkingFromInlineContent|SiliconflowChatExtractsProviderPrefixedQwenThinkingFromInlineContent)' -vet=off -count=1` Note: the full package command `/root/go/bin/go test ./internal/entity/models -vet=off -count=1` now runs locally, but it currently fails on an unrelated existing `TestAstraflowEmbedReturnsNoSuchMethod` panic in `internal/entity/models/astraflow.go:482`. --- internal/entity/models/common.go | 36 ++++- internal/entity/models/common_test.go | 84 ++++++++++ .../models/reasoning_family_provider_test.go | 150 ++++++++++++++++++ internal/entity/models/siliconflow.go | 12 ++ 4 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 internal/entity/models/common_test.go create mode 100644 internal/entity/models/reasoning_family_provider_test.go diff --git a/internal/entity/models/common.go b/internal/entity/models/common.go index 4b1b093167..20ac2e461f 100644 --- a/internal/entity/models/common.go +++ b/internal/entity/models/common.go @@ -19,14 +19,48 @@ package models import "strings" func GetThinkingAndAnswer(modelType *string, content *string) (*string, *string) { - switch *modelType { + if content == nil { + return nil, nil + } + + switch NormalizeModelFamily(modelType) { case "qwen3": return extractThinkContent(content) } return nil, content } +// NormalizeModelFamily normalizes provider-prefixed model class/name strings for shared response parsing. +func NormalizeModelFamily(modelType *string) string { + if modelType == nil { + return "" + } + + family := strings.ToLower(strings.TrimSpace(*modelType)) + if family == "" { + return "" + } + + if slash := strings.LastIndex(family, "/"); slash >= 0 && slash < len(family)-1 { + family = family[slash+1:] + } + + if strings.HasPrefix(family, "qwen3") { + return "qwen3" + } + + if dash := strings.Index(family, "-"); dash >= 0 { + family = family[:dash] + } + + return family +} + func extractThinkContent(content *string) (*string, *string) { + if content == nil { + return nil, nil + } + startTag := "" endTag := "" diff --git a/internal/entity/models/common_test.go b/internal/entity/models/common_test.go new file mode 100644 index 0000000000..f8bc589c59 --- /dev/null +++ b/internal/entity/models/common_test.go @@ -0,0 +1,84 @@ +package models + +import "testing" + +func TestNormalizeModelFamily(t *testing.T) { + tests := []struct { + name string + input *string + want string + }{ + {name: "nil", input: nil, want: ""}, + {name: "empty", input: modelFamilyTestString(""), want: ""}, + {name: "qwen3", input: modelFamilyTestString("qwen3"), want: "qwen3"}, + {name: "qwen3 variant", input: modelFamilyTestString("qwen3-8b"), want: "qwen3"}, + {name: "provider-prefixed qwen3", input: modelFamilyTestString("qwen/qwen3-8b"), want: "qwen3"}, + {name: "case-varied qwen3", input: modelFamilyTestString("Qwen/Qwen3.5-4B"), want: "qwen3"}, + {name: "provider-prefixed non-qwen", input: modelFamilyTestString("deepseek/deepseek-r1"), want: "deepseek"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeModelFamily(tt.input); got != tt.want { + t.Fatalf("NormalizeModelFamily()=%q, want %q", got, tt.want) + } + }) + } +} + +func TestGetThinkingAndAnswerExtractsQwenThinking(t *testing.T) { + tests := []string{ + "qwen3", + "qwen3-8b", + "qwen/qwen3", + "qwen/qwen3-8b", + "Qwen/Qwen3.5-4B", + } + + for _, modelFamily := range tests { + t.Run(modelFamily, func(t *testing.T) { + content := "\nreasoning\nanswer" + reasoning, answer := GetThinkingAndAnswer(&modelFamily, &content) + + if reasoning == nil || *reasoning != "reasoning" { + t.Fatalf("reasoning=%v, want reasoning", reasoning) + } + if answer == nil || *answer != "answer" { + t.Fatalf("answer=%v, want answer", answer) + } + }) + } +} + +func TestGetThinkingAndAnswerSkipsUnknownFamily(t *testing.T) { + modelFamily := "deepseek/deepseek-r1" + content := "reasoninganswer" + + reasoning, answer := GetThinkingAndAnswer(&modelFamily, &content) + if reasoning != nil { + t.Fatalf("reasoning=%q, want nil", *reasoning) + } + if answer == nil || *answer != content { + t.Fatalf("answer=%v, want original content", answer) + } +} + +func TestGetThinkingAndAnswerHandlesNilInputs(t *testing.T) { + reasoning, answer := GetThinkingAndAnswer(nil, nil) + if reasoning != nil || answer != nil { + t.Fatalf("GetThinkingAndAnswer(nil, nil)=(%v, %v), want nils", reasoning, answer) + } + + content := "answer" + reasoning, answer = GetThinkingAndAnswer(nil, &content) + if reasoning != nil { + t.Fatalf("reasoning=%q, want nil", *reasoning) + } + if answer == nil || *answer != content { + t.Fatalf("answer=%v, want original content", answer) + } +} + +func modelFamilyTestString(value string) *string { + return &value +} diff --git a/internal/entity/models/reasoning_family_provider_test.go b/internal/entity/models/reasoning_family_provider_test.go new file mode 100644 index 0000000000..d1b892379a --- /dev/null +++ b/internal/entity/models/reasoning_family_provider_test.go @@ -0,0 +1,150 @@ +package models + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newReasoningFamilyChatServer(t *testing.T, handler func(t *testing.T, body map[string]interface{}, w http.ResponseWriter)) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method=%s, want POST", r.Method) + return + } + if r.URL.Path != "/chat/completions" { + t.Errorf("path=%s, want /chat/completions", r.URL.Path) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization=%q, want Bearer test-key", got) + return + } + if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Errorf("Content-Type=%q, want application/json", got) + return + } + + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + return + } + + var body map[string]interface{} + if err := json.Unmarshal(raw, &body); err != nil { + t.Errorf("unmarshal body: %v\nraw=%s", err, string(raw)) + return + } + + handler(t, body, w) + })) +} + +func TestGiteeChatExtractsQwenThinkingFromInlineContent(t *testing.T) { + srv := newReasoningFamilyChatServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + if body["model"] != "qwen3-8b" { + t.Errorf("model=%v, want qwen3-8b", body["model"]) + } + if !assertThinkingEnabled(t, body) { + return + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{{ + "message": map[string]interface{}{ + "content": "\nreasoning\nanswer", + }, + }}, + }) + }) + defer srv.Close() + + apiKey := "test-key" + thinking := true + modelClass := "qwen3-8b" + resp, err := NewGiteeModel( + map[string]string{"default": srv.URL}, + URLSuffix{Chat: "chat/completions"}, + ).ChatWithMessages( + "qwen3-8b", + []Message{{Role: "user", Content: "ping"}}, + &APIConfig{ApiKey: &apiKey}, + &ChatConfig{Thinking: &thinking, ModelClass: &modelClass}, + ) + if err != nil { + t.Fatalf("ChatWithMessages: %v", err) + } + assertThinkingResponse(t, resp) +} + +func TestSiliconflowChatExtractsProviderPrefixedQwenThinkingFromInlineContent(t *testing.T) { + srv := newReasoningFamilyChatServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + if body["model"] != "qwen/qwen3-8b" { + t.Errorf("model=%v, want qwen/qwen3-8b", body["model"]) + } + if !assertThinkingEnabled(t, body) { + return + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{{ + "message": map[string]interface{}{ + "content": "\nreasoning\nanswer", + }, + }}, + }) + }) + defer srv.Close() + + apiKey := "test-key" + thinking := true + modelClass := "qwen/qwen3-8b" + resp, err := NewSiliconflowModel( + map[string]string{"default": srv.URL}, + URLSuffix{Chat: "chat/completions"}, + ).ChatWithMessages( + "qwen/qwen3-8b", + []Message{{Role: "user", Content: "ping"}}, + &APIConfig{ApiKey: &apiKey}, + &ChatConfig{Thinking: &thinking, ModelClass: &modelClass}, + ) + if err != nil { + t.Fatalf("ChatWithMessages: %v", err) + } + assertThinkingResponse(t, resp) +} + +func assertThinkingEnabled(t *testing.T, body map[string]interface{}) bool { + t.Helper() + + thinking, ok := body["thinking"].(map[string]interface{}) + if !ok { + t.Errorf("thinking=%#v, want object", body["thinking"]) + return false + } + if thinking["type"] != "enabled" { + t.Errorf("thinking.type=%v, want enabled", thinking["type"]) + return false + } + return true +} + +func assertThinkingResponse(t *testing.T, resp *ChatResponse) { + t.Helper() + + if resp == nil { + t.Fatal("response is nil") + } + if resp.Answer == nil || *resp.Answer != "answer" { + t.Fatalf("Answer=%v, want answer", resp.Answer) + } + if resp.ReasonContent == nil || *resp.ReasonContent != "reasoning" { + t.Fatalf("ReasonContent=%v, want reasoning", resp.ReasonContent) + } +} diff --git a/internal/entity/models/siliconflow.go b/internal/entity/models/siliconflow.go index b72233c621..32c0fc5e86 100644 --- a/internal/entity/models/siliconflow.go +++ b/internal/entity/models/siliconflow.go @@ -128,6 +128,18 @@ func (s *SiliconflowModel) ChatWithMessages(modelName string, messages []Message if chatModelConfig.Stop != nil { reqBody["stop"] = *chatModelConfig.Stop } + + if chatModelConfig.Thinking != nil { + if *chatModelConfig.Thinking { + reqBody["thinking"] = map[string]interface{}{ + "type": "enabled", + } + } else { + reqBody["thinking"] = map[string]interface{}{ + "type": "disabled", + } + } + } } jsonData, err := json.Marshal(reqBody)