diff --git a/core/http/endpoints/openai/chat.go b/core/http/endpoints/openai/chat.go index 4ece68d5c..a191c612c 100644 --- a/core/http/endpoints/openai/chat.go +++ b/core/http/endpoints/openai/chat.go @@ -43,10 +43,18 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator lastEmittedReasoning := "" lastEmittedCleanedContent := "" + // Configure reasoning extraction options + // Auto-detect if prompt ends with thinking tag (like llama.cpp does) + // or use explicit config setting + thinkingForcedOpen := config.FunctionsConfig.ThinkingForcedOpen || functions.DetectThinkingForcedOpen(s) + reasoningOpts := functions.ReasoningOptions{ + ThinkingForcedOpen: thinkingForcedOpen, + } + _, _, err := ComputeChoices(req, s, config, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, tokenUsage backend.TokenUsage) bool { accumulatedContent += s - // Extract reasoning from accumulated content - currentReasoning, cleanedContent := functions.ExtractReasoning(accumulatedContent) + // Extract reasoning from accumulated content with options + currentReasoning, cleanedContent := functions.ExtractReasoning(accumulatedContent, reasoningOpts) // Calculate new reasoning delta (what we haven't emitted yet) var reasoningDelta *string @@ -230,7 +238,12 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator return err } // Extract reasoning before processing tool calls - reasoning, cleanedResult := functions.ExtractReasoning(result) + // Auto-detect if prompt ends with thinking tag or use explicit config + toolsThinkingForcedOpen := config.FunctionsConfig.ThinkingForcedOpen || functions.DetectThinkingForcedOpen(prompt) + toolsReasoningOpts := functions.ReasoningOptions{ + ThinkingForcedOpen: toolsThinkingForcedOpen, + } + reasoning, cleanedResult := functions.ExtractReasoning(result, toolsReasoningOpts) result = cleanedResult textContentToReturn = functions.ParseTextContent(result, config.FunctionsConfig) @@ -618,9 +631,15 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator // no streaming mode default: + // Auto-detect if prompt ends with thinking tag for non-streaming mode + nonStreamThinkingForcedOpen := config.FunctionsConfig.ThinkingForcedOpen || functions.DetectThinkingForcedOpen(predInput) + tokenCallback := func(s string, c *[]schema.Choice) { // Extract reasoning from the response - reasoning, cleanedS := functions.ExtractReasoning(s) + nonStreamReasoningOpts := functions.ReasoningOptions{ + ThinkingForcedOpen: nonStreamThinkingForcedOpen, + } + reasoning, cleanedS := functions.ExtractReasoning(s, nonStreamReasoningOpts) s = cleanedS if !shouldUseFn { diff --git a/pkg/functions/parse.go b/pkg/functions/parse.go index 9f14208f1..0fca6514e 100644 --- a/pkg/functions/parse.go +++ b/pkg/functions/parse.go @@ -111,6 +111,11 @@ type FunctionsConfig struct { // XMLFormat is an optional custom XML format configuration // If set, only this format will be tried (overrides XMLFormatPreset) XMLFormat *XMLToolCallFormat `yaml:"xml_format,omitempty" json:"xml_format,omitempty"` + + // ThinkingForcedOpen indicates that the model outputs reasoning without an opening tag. + // When true, all content from the start is treated as reasoning until a closing tag is found. + // This is useful for models like GLM-4 that output reasoning without but end with . + ThinkingForcedOpen bool `yaml:"thinking_forced_open,omitempty" json:"thinking_forced_open,omitempty"` } // @Description ReplaceResult defines a key-value replacement for function results diff --git a/pkg/functions/reasoning.go b/pkg/functions/reasoning.go index d3cf05808..96fd098c5 100644 --- a/pkg/functions/reasoning.go +++ b/pkg/functions/reasoning.go @@ -4,11 +4,107 @@ import ( "strings" ) +// Common thinking/reasoning opening tags used by various models +var thinkingOpenTags = []string{ + "\n", + "", + "\n", + "", + "<|inner_prefix|>", // Apertus + "<|START_THINKING|>", // Command R7B + "", // Seed + "[THINK]\n", // Magistral + "[THINK]", +} + +// ReasoningOptions configures how reasoning extraction behaves +type ReasoningOptions struct { + // ThinkingForcedOpen indicates that the model outputs reasoning without an opening tag. + // When true, all content from the start is treated as reasoning until a closing tag is found. + // This is useful for models like GLM-4 that output reasoning without but end with . + ThinkingForcedOpen bool +} + +// DetectThinkingForcedOpen checks if a prompt ends with a thinking opening tag. +// This is used to automatically detect when the model template has already added +// the opening thinking tag, meaning the model will output reasoning content directly. +// Returns true if the prompt ends with a known thinking opening tag. +func DetectThinkingForcedOpen(prompt string) bool { + for _, tag := range thinkingOpenTags { + if strings.HasSuffix(prompt, tag) { + return true + } + } + return false +} + // ExtractReasoning extracts reasoning content from thinking tags and returns // both the extracted reasoning and the cleaned content (with tags removed). // It handles ... and ... tags. // Multiple reasoning blocks are concatenated with newlines. -func ExtractReasoning(content string) (reasoning string, cleanedContent string) { +// It also handles the case where only a closing tag is present (no opening tag), +// in which case everything before the closing tag is treated as reasoning. +// +// When opts.ThinkingForcedOpen is true, all content from the start is treated as reasoning +// until a closing tag ( or ) is found. This is useful for models +// whose templates add the opening tag, so the model outputs reasoning directly. +func ExtractReasoning(content string, opts ReasoningOptions) (reasoning string, cleanedContent string) { + if content == "" { + return "", content + } + + if opts.ThinkingForcedOpen { + return extractReasoningForcedOpen(content) + } + + return extractReasoningFromTags(content) +} + +// extractReasoningForcedOpen handles the case where reasoning starts without an opening tag. +// All content from the start is treated as reasoning until a closing tag is found. +func extractReasoningForcedOpen(content string) (reasoning string, cleanedContent string) { + // Look for the earliest closing tag + closingTags := []string{"", ""} + + earliestCloseIdx := -1 + var matchedCloseTag string + + for _, closeTag := range closingTags { + idx := strings.Index(content, closeTag) + if idx != -1 && (earliestCloseIdx == -1 || idx < earliestCloseIdx) { + earliestCloseIdx = idx + matchedCloseTag = closeTag + } + } + + if earliestCloseIdx == -1 { + // No closing tag found - all content is reasoning (still streaming) + return strings.TrimSpace(content), "" + } + + // Found closing tag - everything before is reasoning, everything after is content + reasoning = strings.TrimSpace(content[:earliestCloseIdx]) + cleanedContent = content[earliestCloseIdx+len(matchedCloseTag):] + + // Continue processing the rest for any additional reasoning blocks + if cleanedContent != "" { + additionalReasoning, finalContent := extractReasoningFromTags(cleanedContent) + if additionalReasoning != "" { + if reasoning != "" { + reasoning = reasoning + "\n\n" + additionalReasoning + } else { + reasoning = additionalReasoning + } + } + cleanedContent = finalContent + } + + return reasoning, cleanedContent +} + +// extractReasoningFromTags extracts reasoning content from thinking tags. +// This is the core implementation that handles standard tag-based extraction. +func extractReasoningFromTags(content string) (reasoning string, cleanedContent string) { if content == "" { return "", content } @@ -34,6 +130,7 @@ func ExtractReasoning(content string) (reasoning string, cleanedContent string) earliestStart := -1 earliestEnd := -1 isUnclosed := false + isClosingOnly := false var matchedTag struct { start string end string @@ -41,30 +138,48 @@ func ExtractReasoning(content string) (reasoning string, cleanedContent string) for _, tagPair := range tagPairs { startIdx := strings.Index(remaining[lastPos:], tagPair.start) + endIdx := strings.Index(remaining[lastPos:], tagPair.end) + + // Check for closing-only tag (closing tag appears before or without opening tag) + if endIdx != -1 && (startIdx == -1 || endIdx < startIdx) { + // Found a closing tag without a preceding opening tag + closingTagPos := endIdx + lastPos + if earliestStart == -1 || closingTagPos < earliestStart || (isClosingOnly && closingTagPos < earliestEnd) { + earliestStart = lastPos + earliestEnd = closingTagPos + len(tagPair.end) + isClosingOnly = true + isUnclosed = false + matchedTag = tagPair + } + continue + } + if startIdx == -1 { continue } startIdx += lastPos - // Find the corresponding end tag - endIdx := strings.Index(remaining[startIdx+len(tagPair.start):], tagPair.end) - if endIdx == -1 { + // Find the corresponding end tag after the start tag + endIdxAfterStart := strings.Index(remaining[startIdx+len(tagPair.start):], tagPair.end) + if endIdxAfterStart == -1 { // Unclosed tag - extract what we have if earliestStart == -1 || startIdx < earliestStart { earliestStart = startIdx earliestEnd = len(remaining) isUnclosed = true + isClosingOnly = false matchedTag = tagPair } continue } - endIdx += startIdx + len(tagPair.start) + endIdxAfterStart += startIdx + len(tagPair.start) // Found a complete tag pair if earliestStart == -1 || startIdx < earliestStart { earliestStart = startIdx - earliestEnd = endIdx + len(tagPair.end) + earliestEnd = endIdxAfterStart + len(tagPair.end) isUnclosed = false + isClosingOnly = false matchedTag = tagPair } } @@ -77,6 +192,17 @@ func ExtractReasoning(content string) (reasoning string, cleanedContent string) break } + if isClosingOnly { + // Closing tag without opening tag - content before closing tag is reasoning + reasoningContent := strings.TrimSpace(remaining[lastPos : earliestEnd-len(matchedTag.end)]) + if reasoningContent != "" { + reasoningParts = append(reasoningParts, reasoningContent) + } + // Move past the closing tag + lastPos = earliestEnd + continue + } + // Add content before the tag if earliestStart > lastPos { cleanedParts = append(cleanedParts, remaining[lastPos:earliestStart]) diff --git a/pkg/functions/reasoning_test.go b/pkg/functions/reasoning_test.go index 3f7d07541..d60bb23e1 100644 --- a/pkg/functions/reasoning_test.go +++ b/pkg/functions/reasoning_test.go @@ -8,25 +8,58 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("DetectThinkingForcedOpen", func() { + It("should detect at end of prompt", func() { + Expect(DetectThinkingForcedOpen("Some prompt")).To(BeTrue()) + Expect(DetectThinkingForcedOpen("Some prompt\n")).To(BeTrue()) + }) + + It("should detect at end of prompt", func() { + Expect(DetectThinkingForcedOpen("Some prompt")).To(BeTrue()) + Expect(DetectThinkingForcedOpen("Some prompt\n")).To(BeTrue()) + }) + + It("should detect model-specific tags", func() { + Expect(DetectThinkingForcedOpen("Some prompt<|inner_prefix|>")).To(BeTrue()) + Expect(DetectThinkingForcedOpen("Some prompt<|START_THINKING|>")).To(BeTrue()) + Expect(DetectThinkingForcedOpen("Some prompt")).To(BeTrue()) + Expect(DetectThinkingForcedOpen("Some prompt[THINK]")).To(BeTrue()) + Expect(DetectThinkingForcedOpen("Some prompt[THINK]\n")).To(BeTrue()) + }) + + It("should not detect if tag is in the middle", func() { + Expect(DetectThinkingForcedOpen("Some prompt")).To(BeFalse()) + Expect(DetectThinkingForcedOpen("reasoning")).To(BeFalse()) + }) + + It("should not detect if no thinking tag", func() { + Expect(DetectThinkingForcedOpen("Some regular prompt")).To(BeFalse()) + Expect(DetectThinkingForcedOpen("")).To(BeFalse()) + }) +}) + var _ = Describe("ExtractReasoning", func() { + // Default options (ThinkingForcedOpen = false) + defaultOpts := ReasoningOptions{} + Context("when content has no reasoning tags", func() { It("should return empty reasoning and original content", func() { content := "This is regular content without any tags." - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal(content)) }) It("should handle empty string", func() { content := "" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(BeEmpty()) }) It("should handle content with only whitespace", func() { content := " \n\t " - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal(content)) }) @@ -35,42 +68,42 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has tags", func() { It("should extract reasoning from single thinking block", func() { content := "Some text This is my reasoning More text" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("This is my reasoning")) Expect(cleaned).To(Equal("Some text More text")) }) It("should extract reasoning and preserve surrounding content", func() { content := "Before Reasoning here After" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Reasoning here")) Expect(cleaned).To(Equal("Before After")) }) It("should handle thinking block at the start", func() { content := "Start reasoning Regular content" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Start reasoning")) Expect(cleaned).To(Equal(" Regular content")) }) It("should handle thinking block at the end", func() { content := "Regular content End reasoning" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("End reasoning")) Expect(cleaned).To(Equal("Regular content ")) }) It("should handle only thinking block", func() { content := "Only reasoning" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Only reasoning")) Expect(cleaned).To(BeEmpty()) }) It("should trim whitespace from reasoning content", func() { content := "Text \n Reasoning with spaces \n More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Reasoning with spaces")) Expect(cleaned).To(Equal("Text More")) }) @@ -79,21 +112,21 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has tags", func() { It("should extract reasoning from redacted_reasoning block", func() { content := "Text Redacted reasoning More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Redacted reasoning")) Expect(cleaned).To(Equal("Text More")) }) It("should handle redacted_reasoning with multiline content", func() { content := "Before Line 1\nLine 2\nLine 3 After" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3")) Expect(cleaned).To(Equal("Before After")) }) It("should handle redacted_reasoning with complex content", func() { content := "Start Complex reasoning\nwith\nmultiple\nlines End" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Complex reasoning\nwith\nmultiple\nlines")) Expect(cleaned).To(Equal("Start End")) }) @@ -102,14 +135,14 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has multiple reasoning blocks", func() { It("should concatenate multiple thinking blocks with newlines", func() { content := "Text First Middle Second End" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("First\n\nSecond")) Expect(cleaned).To(Equal("Text Middle End")) }) It("should handle multiple different tag types", func() { content := "A One B Two C Three D" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(ContainSubstring("One")) Expect(reasoning).To(ContainSubstring("Two")) Expect(reasoning).To(ContainSubstring("Three")) @@ -118,7 +151,7 @@ var _ = Describe("ExtractReasoning", func() { It("should handle nested tags correctly (extracts first match)", func() { content := "Text Outer Inner More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) // Should extract the outer thinking block Expect(reasoning).To(ContainSubstring("Outer")) Expect(reasoning).To(ContainSubstring("Inner")) @@ -129,28 +162,28 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has unclosed reasoning tags", func() { It("should extract unclosed thinking block", func() { content := "Text Unclosed reasoning" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Unclosed reasoning")) Expect(cleaned).To(Equal("Text ")) }) It("should extract unclosed think block", func() { content := "Before Incomplete" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Incomplete")) Expect(cleaned).To(Equal("Before ")) }) It("should extract unclosed redacted_reasoning block", func() { content := "Start Partial reasoning content" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Partial reasoning content")) Expect(cleaned).To(Equal("Start ")) }) It("should handle unclosed tag at the end", func() { content := "Regular content Unclosed at end" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Unclosed at end")) Expect(cleaned).To(Equal("Regular content ")) }) @@ -159,14 +192,14 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has empty reasoning blocks", func() { It("should ignore empty thinking block", func() { content := "Text More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal("Text More")) }) It("should ignore thinking block with only whitespace", func() { content := "Text \n\t More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal("Text More")) }) @@ -175,28 +208,28 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has reasoning tags with special characters", func() { It("should handle reasoning with newlines", func() { content := "Before Line 1\nLine 2\nLine 3 After" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3")) Expect(cleaned).To(Equal("Before After")) }) It("should handle reasoning with code blocks", func() { content := "Text Reasoning with ```code``` blocks More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Reasoning with ```code``` blocks")) Expect(cleaned).To(Equal("Text More")) }) It("should handle reasoning with JSON", func() { content := "Before {\"key\": \"value\"} After" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("{\"key\": \"value\"}")) Expect(cleaned).To(Equal("Before After")) }) It("should handle reasoning with HTML-like content", func() { content := "Text Reasoning with inside More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Reasoning with inside")) Expect(cleaned).To(Equal("Text More")) }) @@ -205,7 +238,7 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has reasoning mixed with regular content", func() { It("should preserve content order correctly", func() { content := "Start Reasoning Middle More reasoning End" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(ContainSubstring("Reasoning")) Expect(reasoning).To(ContainSubstring("More reasoning")) Expect(cleaned).To(Equal("Start Middle End")) @@ -213,30 +246,59 @@ var _ = Describe("ExtractReasoning", func() { It("should handle reasoning in the middle of a sentence", func() { content := "This is a reasoning sentence." - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("reasoning")) Expect(cleaned).To(Equal("This is a sentence.")) }) }) - Context("edge cases", func() { + Context("edge cases without ThinkingForcedOpen", func() { It("should handle content with only opening tag", func() { content := "" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal("")) }) - It("should handle content with only closing tag", func() { + It("should handle content with only closing tag (no content before)", func() { content := "" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(BeEmpty()) - Expect(cleaned).To(Equal("")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should extract reasoning when only closing tag is present", func() { + // GLM-4 style: reasoning content followed by closing tag without opening tag + content := "This is reasoning contentthis is the actual response" + reasoning, cleaned := ExtractReasoning(content, defaultOpts) + Expect(reasoning).To(Equal("This is reasoning content")) + Expect(cleaned).To(Equal("this is the actual response")) + }) + + It("should handle closing-only tag with multiline reasoning", func() { + content := "1. First point\n2. Second point\n3. Third pointFinal answer" + reasoning, cleaned := ExtractReasoning(content, defaultOpts) + Expect(reasoning).To(Equal("1. First point\n2. Second point\n3. Third point")) + Expect(cleaned).To(Equal("Final answer")) + }) + + It("should handle closing-only tag with complex reasoning (GLM-4 example)", func() { + content := "**Analyze the user's input:** The user says something.\n\n**Final Decision:** Output the text.this is a test" + reasoning, cleaned := ExtractReasoning(content, defaultOpts) + Expect(reasoning).To(Equal("**Analyze the user's input:** The user says something.\n\n**Final Decision:** Output the text.")) + Expect(cleaned).To(Equal("this is a test")) + }) + + It("should handle closing-only thinking tag", func() { + content := "Some reasoning hereactual content" + reasoning, cleaned := ExtractReasoning(content, defaultOpts) + Expect(reasoning).To(Equal("Some reasoning here")) + Expect(cleaned).To(Equal("actual content")) }) It("should handle mismatched tags", func() { content := "Content" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) // Should extract unclosed thinking block Expect(reasoning).To(ContainSubstring("Content")) Expect(cleaned).To(Equal("")) @@ -245,7 +307,7 @@ var _ = Describe("ExtractReasoning", func() { It("should handle very long reasoning content", func() { longReasoning := strings.Repeat("This is reasoning content. ", 100) content := "Text " + longReasoning + " More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) // TrimSpace is applied, so we need to account for that Expect(reasoning).To(Equal(strings.TrimSpace(longReasoning))) Expect(cleaned).To(Equal("Text More")) @@ -253,9 +315,76 @@ var _ = Describe("ExtractReasoning", func() { It("should handle reasoning with unicode characters", func() { content := "Text Reasoning with 中文 and emoji 🧠 More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, defaultOpts) Expect(reasoning).To(Equal("Reasoning with 中文 and emoji 🧠")) Expect(cleaned).To(Equal("Text More")) }) }) + + Context("when ThinkingForcedOpen is true", func() { + forcedOpenOpts := ReasoningOptions{ThinkingForcedOpen: true} + + It("should treat all content as reasoning until closing tag", func() { + content := "This is reasoningthis is content" + reasoning, cleaned := ExtractReasoning(content, forcedOpenOpts) + Expect(reasoning).To(Equal("This is reasoning")) + Expect(cleaned).To(Equal("this is content")) + }) + + It("should treat all content as reasoning when no closing tag (streaming)", func() { + content := "This is reasoning content still streaming" + reasoning, cleaned := ExtractReasoning(content, forcedOpenOpts) + Expect(reasoning).To(Equal("This is reasoning content still streaming")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should handle GLM-4 style output", func() { + content := "**Analyze:** The user says something.\n\n**Final Decision:** Output the text.this is a test" + reasoning, cleaned := ExtractReasoning(content, forcedOpenOpts) + Expect(reasoning).To(Equal("**Analyze:** The user says something.\n\n**Final Decision:** Output the text.")) + Expect(cleaned).To(Equal("this is a test")) + }) + + It("should handle multiline reasoning with closing tag", func() { + content := "1. First point\n2. Second point\n3. Third pointFinal answer" + reasoning, cleaned := ExtractReasoning(content, forcedOpenOpts) + Expect(reasoning).To(Equal("1. First point\n2. Second point\n3. Third point")) + Expect(cleaned).To(Equal("Final answer")) + }) + + It("should handle closing tag", func() { + content := "Some reasoning hereactual content" + reasoning, cleaned := ExtractReasoning(content, forcedOpenOpts) + Expect(reasoning).To(Equal("Some reasoning here")) + Expect(cleaned).To(Equal("actual content")) + }) + + It("should handle additional reasoning blocks after initial forced open", func() { + content := "Initial reasoningcontentmore reasoningfinal content" + reasoning, cleaned := ExtractReasoning(content, forcedOpenOpts) + Expect(reasoning).To(Equal("Initial reasoning\n\nmore reasoning")) + Expect(cleaned).To(Equal("contentfinal content")) + }) + + It("should handle empty content", func() { + reasoning, cleaned := ExtractReasoning("", forcedOpenOpts) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(BeEmpty()) + }) + + It("should handle only closing tag", func() { + content := "only content" + reasoning, cleaned := ExtractReasoning(content, forcedOpenOpts) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("only content")) + }) + + It("should find earliest closing tag", func() { + // comes before + content := "Reasoningcontentmore" + reasoning, cleaned := ExtractReasoning(content, forcedOpenOpts) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("contentmore")) + }) + }) })