diff --git a/core/http/endpoints/openai/chat.go b/core/http/endpoints/openai/chat.go index 2075a0368..b35f2bab8 100644 --- a/core/http/endpoints/openai/chat.go +++ b/core/http/endpoints/openai/chat.go @@ -47,7 +47,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator } else { template = s } - thinkingStartToken := reason.DetectThinkingStartToken(template) + thinkingStartToken := reason.DetectThinkingStartToken(template, &config.ReasoningConfig) // Track accumulated content for reasoning extraction accumulatedContent := "" @@ -56,12 +56,8 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator _, _, err := ComputeChoices(req, s, config, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, tokenUsage backend.TokenUsage) bool { accumulatedContent += s - content := accumulatedContent - // Prepend thinking token if needed, then extract reasoning - if config.ReasoningConfig.DisableReasoningTagPrefill == nil || !*config.ReasoningConfig.DisableReasoningTagPrefill { - content = reason.PrependThinkingTokenIfNeeded(content, thinkingStartToken) - } - currentReasoning, cleanedContent := reason.ExtractReasoning(content) + + currentReasoning, cleanedContent := reason.ExtractReasoningWithConfig(accumulatedContent, thinkingStartToken, config.ReasoningConfig) // Calculate new reasoning delta (what we haven't emitted yet) var reasoningDelta *string @@ -140,7 +136,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator } else { template = prompt } - thinkingStartToken := reason.DetectThinkingStartToken(template) + thinkingStartToken := reason.DetectThinkingStartToken(template, &config.ReasoningConfig) result := "" lastEmittedCount := 0 @@ -254,12 +250,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator return err } // Prepend thinking token if needed, then extract reasoning before processing tool calls - resultWithToken := result - if config.ReasoningConfig.DisableReasoningTagPrefill == nil || !*config.ReasoningConfig.DisableReasoningTagPrefill { - resultWithToken = reason.PrependThinkingTokenIfNeeded(result, thinkingStartToken) - } - reasoning, cleanedResult := reason.ExtractReasoning(resultWithToken) - result = cleanedResult + reasoning, result := reason.ExtractReasoningWithConfig(result, thinkingStartToken, config.ReasoningConfig) textContentToReturn = functions.ParseTextContent(result, config.FunctionsConfig) result = functions.CleanupLLMResult(result, config.FunctionsConfig) @@ -652,18 +643,13 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator } else { template = predInput } - thinkingStartToken := reason.DetectThinkingStartToken(template) + thinkingStartToken := reason.DetectThinkingStartToken(template, &config.ReasoningConfig) xlog.Debug("Thinking start token", "thinkingStartToken", thinkingStartToken, "template", template) tokenCallback := func(s string, c *[]schema.Choice) { // Prepend thinking token if needed, then extract reasoning from the response - sWithToken := s - if config.ReasoningConfig.DisableReasoningTagPrefill == nil || !*config.ReasoningConfig.DisableReasoningTagPrefill { - sWithToken = reason.PrependThinkingTokenIfNeeded(s, thinkingStartToken) - } - reasoning, cleanedS := reason.ExtractReasoning(sWithToken) - s = cleanedS + reasoning, s := reason.ExtractReasoningWithConfig(s, thinkingStartToken, config.ReasoningConfig) if !shouldUseFn { // no function is called, just reply and use stop as finish reason diff --git a/core/http/endpoints/openresponses/responses.go b/core/http/endpoints/openresponses/responses.go index 337978506..aa2702dcf 100644 --- a/core/http/endpoints/openresponses/responses.go +++ b/core/http/endpoints/openresponses/responses.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net" + "strings" "time" "github.com/google/uuid" @@ -18,6 +19,7 @@ import ( "github.com/mudler/LocalAI/core/templates" "github.com/mudler/LocalAI/pkg/functions" "github.com/mudler/LocalAI/pkg/model" + reason "github.com/mudler/LocalAI/pkg/reasoning" "github.com/mudler/LocalAI/pkg/utils" "github.com/mudler/cogito" "github.com/mudler/xlog" @@ -1330,13 +1332,37 @@ func handleOpenResponsesNonStream(c echo.Context, responseID string, createdAt i result := backend.Finetune(*cfg, predInput, prediction.Response) xlog.Debug("Open Responses - Raw model result", "result", result, "shouldUseFn", shouldUseFn) + // Detect if thinking token is already in prompt or template + var template string + if cfg.TemplateConfig.UseTokenizerTemplate { + template = cfg.GetModelTemplate() + } else { + template = predInput + } + thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig) + + // Extract reasoning from result before cleaning + reasoningContent, cleanedResult := reason.ExtractReasoningWithConfig(result, thinkingStartToken, cfg.ReasoningConfig) + // Parse tool calls if using functions var outputItems []schema.ORItemField var toolCalls []schema.ToolCall + // Add reasoning item if reasoning was found (reasoning comes first per spec) + if reasoningContent != "" { + reasoningItem := schema.ORItemField{ + Type: "reasoning", + ID: fmt.Sprintf("reasoning_%s", uuid.New().String()), + Status: "completed", + Content: []schema.ORContentPart{makeOutputTextPart(reasoningContent)}, + } + outputItems = append(outputItems, reasoningItem) + xlog.Debug("Open Responses - Extracted reasoning", "reasoning_length", len(reasoningContent)) + } + if shouldUseFn { - // Clean up the result first (handle reasoning tags, etc.) - cleanedResult := functions.CleanupLLMResult(result, cfg.FunctionsConfig) + // Clean up the result (already extracted reasoning above) + cleanedResult = functions.CleanupLLMResult(cleanedResult, cfg.FunctionsConfig) xlog.Debug("Open Responses - Cleaned result", "cleanedResult", cleanedResult) funcCallResults := functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig) @@ -1398,28 +1424,46 @@ func handleOpenResponsesNonStream(c echo.Context, responseID string, createdAt i }) } - // If we have no output items but the model did produce output, include the raw result as a message + // If we have no output items but the model did produce output, include the cleaned result as a message // This handles cases where the function call parsing failed but we still have model output - if len(outputItems) == 0 && result != "" { - xlog.Debug("Open Responses - No parsed output, falling back to raw result") + // Note: reasoning item may already be added above + hasMessageItem := false + for _, item := range outputItems { + if item.Type == "message" { + hasMessageItem = true + break + } + } + if !hasMessageItem && cleanedResult != "" { + xlog.Debug("Open Responses - No parsed output, falling back to cleaned result") outputItems = append(outputItems, schema.ORItemField{ Type: "message", ID: fmt.Sprintf("msg_%s", uuid.New().String()), Status: "completed", Role: "assistant", - Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(result, prediction.Logprobs)}, + Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(cleanedResult, prediction.Logprobs)}, }) } } else { // Simple text response (include logprobs if available) - outputItems = []schema.ORItemField{ - { - Type: "message", - ID: fmt.Sprintf("msg_%s", uuid.New().String()), - Status: "completed", - Role: "assistant", - Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(result, prediction.Logprobs)}, - }, + // Note: reasoning item may already be added above + messageItem := schema.ORItemField{ + Type: "message", + ID: fmt.Sprintf("msg_%s", uuid.New().String()), + Status: "completed", + Role: "assistant", + Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(cleanedResult, prediction.Logprobs)}, + } + outputItems = append(outputItems, messageItem) + } + + // Calculate reasoning tokens (approximate: character count / 4) + reasoningTokens := 0 + if reasoningContent != "" { + // Simple estimation: ~4 characters per token + reasoningTokens = len(reasoningContent) / 4 + if reasoningTokens == 0 && len(reasoningContent) > 0 { + reasoningTokens = 1 } } @@ -1429,6 +1473,9 @@ func handleOpenResponsesNonStream(c echo.Context, responseID string, createdAt i InputTokens: prediction.Usage.Prompt, OutputTokens: prediction.Usage.Completion, TotalTokens: prediction.Usage.Prompt + prediction.Usage.Completion, + OutputTokensDetails: &schema.OROutputTokensDetails{ + ReasoningTokens: reasoningTokens, + }, }, shouldStore) // Store response for future reference (if enabled) @@ -1484,6 +1531,15 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 } } + // Detect if thinking token is already in prompt or template + var template string + if cfg.TemplateConfig.UseTokenizerTemplate { + template = cfg.GetModelTemplate() + } else { + template = predInput + } + thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig) + // Track state for streaming var currentMessageID string var currentContentIndex int @@ -1492,6 +1548,14 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 outputIndex := 0 inToolCallMode := false + // Track reasoning state for streaming + var currentReasoningID string + var currentReasoningContentIndex int + var accumulatedContent string + var lastEmittedReasoning string + var lastEmittedCleanedContent string + var reasoningTokens int + // Collect all output items for storage var collectedOutputItems []schema.ORItemField @@ -1646,52 +1710,133 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 return true } - // If no tool calls detected yet, emit text delta + // If no tool calls detected yet, handle reasoning and text if !inToolCallMode { - if currentMessageID == "" { - // Emit output_item.added for message - currentMessageID = fmt.Sprintf("msg_%s", uuid.New().String()) - messageItem := &schema.ORItemField{ - Type: "message", - ID: currentMessageID, - Status: "in_progress", - Role: "assistant", - Content: []schema.ORContentPart{}, - } - sendSSEEvent(c, &schema.ORStreamEvent{ - Type: "response.output_item.added", - SequenceNumber: sequenceNumber, - OutputIndex: &outputIndex, - Item: messageItem, - }) - sequenceNumber++ + accumulatedContent += token + currentReasoning, cleanedContent := reason.ExtractReasoningWithConfig(accumulatedContent, thinkingStartToken, cfg.ReasoningConfig) - // Emit content_part.added - currentContentIndex = 0 - emptyPart := makeOutputTextPart("") + // Handle reasoning item + if currentReasoning != "" { + // Check if we need to create reasoning item + if currentReasoningID == "" { + outputIndex++ + currentReasoningID = fmt.Sprintf("reasoning_%s", uuid.New().String()) + reasoningItem := &schema.ORItemField{ + Type: "reasoning", + ID: currentReasoningID, + Status: "in_progress", + } + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_item.added", + SequenceNumber: sequenceNumber, + OutputIndex: &outputIndex, + Item: reasoningItem, + }) + sequenceNumber++ + + // Emit content_part.added for reasoning + currentReasoningContentIndex = 0 + emptyPart := makeOutputTextPart("") + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.content_part.added", + SequenceNumber: sequenceNumber, + ItemID: currentReasoningID, + OutputIndex: &outputIndex, + ContentIndex: ¤tReasoningContentIndex, + Part: &emptyPart, + }) + sequenceNumber++ + } + + // Calculate reasoning delta + var reasoningDelta string + if len(currentReasoning) > len(lastEmittedReasoning) && strings.HasPrefix(currentReasoning, lastEmittedReasoning) { + reasoningDelta = currentReasoning[len(lastEmittedReasoning):] + lastEmittedReasoning = currentReasoning + } else if currentReasoning != lastEmittedReasoning { + reasoningDelta = currentReasoning + lastEmittedReasoning = currentReasoning + } + + // Emit reasoning delta if there's new content + if reasoningDelta != "" { + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_text.delta", + SequenceNumber: sequenceNumber, + ItemID: currentReasoningID, + OutputIndex: &outputIndex, + ContentIndex: ¤tReasoningContentIndex, + Delta: strPtr(reasoningDelta), + Logprobs: emptyLogprobs(), + }) + sequenceNumber++ + c.Response().Flush() + } + } + + // Handle message content (cleaned content without reasoning tags) + var deltaContent string + if len(cleanedContent) > len(lastEmittedCleanedContent) && strings.HasPrefix(cleanedContent, lastEmittedCleanedContent) { + deltaContent = cleanedContent[len(lastEmittedCleanedContent):] + lastEmittedCleanedContent = cleanedContent + } else if cleanedContent != lastEmittedCleanedContent { + if lastEmittedCleanedContent == "" { + deltaContent = cleanedContent + lastEmittedCleanedContent = cleanedContent + } else { + deltaContent = cleanedContent + lastEmittedCleanedContent = cleanedContent + } + } + + // Only emit message content if there's actual content (not just reasoning) + if deltaContent != "" { + if currentMessageID == "" { + // Emit output_item.added for message + outputIndex++ + currentMessageID = fmt.Sprintf("msg_%s", uuid.New().String()) + messageItem := &schema.ORItemField{ + Type: "message", + ID: currentMessageID, + Status: "in_progress", + Role: "assistant", + Content: []schema.ORContentPart{}, + } + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_item.added", + SequenceNumber: sequenceNumber, + OutputIndex: &outputIndex, + Item: messageItem, + }) + sequenceNumber++ + + // Emit content_part.added + currentContentIndex = 0 + emptyPart := makeOutputTextPart("") + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.content_part.added", + SequenceNumber: sequenceNumber, + ItemID: currentMessageID, + OutputIndex: &outputIndex, + ContentIndex: ¤tContentIndex, + Part: &emptyPart, + }) + sequenceNumber++ + } + + // Emit text delta sendSSEEvent(c, &schema.ORStreamEvent{ - Type: "response.content_part.added", + Type: "response.output_text.delta", SequenceNumber: sequenceNumber, ItemID: currentMessageID, OutputIndex: &outputIndex, ContentIndex: ¤tContentIndex, - Part: &emptyPart, + Delta: strPtr(deltaContent), + Logprobs: emptyLogprobs(), }) sequenceNumber++ + c.Response().Flush() } - - // Emit text delta - sendSSEEvent(c, &schema.ORStreamEvent{ - Type: "response.output_text.delta", - SequenceNumber: sequenceNumber, - ItemID: currentMessageID, - OutputIndex: &outputIndex, - ContentIndex: ¤tContentIndex, - Delta: strPtr(token), - Logprobs: emptyLogprobs(), - }) - sequenceNumber++ - c.Response().Flush() } return true } @@ -1754,7 +1899,62 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 } result := backend.Finetune(*cfg, predInput, prediction.Response) - cleanedResult := functions.CleanupLLMResult(result, cfg.FunctionsConfig) + + // Extract reasoning from final result + finalReasoning, finalCleanedResult := reason.ExtractReasoningWithConfig(result, thinkingStartToken, cfg.ReasoningConfig) + + // Close reasoning item if it exists and wasn't closed yet + if currentReasoningID != "" && finalReasoning != "" { + // Emit output_text.done for reasoning + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_text.done", + SequenceNumber: sequenceNumber, + ItemID: currentReasoningID, + OutputIndex: &outputIndex, + ContentIndex: ¤tReasoningContentIndex, + Text: strPtr(finalReasoning), + Logprobs: emptyLogprobs(), + }) + sequenceNumber++ + + // Emit content_part.done for reasoning + reasoningPart := makeOutputTextPart(finalReasoning) + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.content_part.done", + SequenceNumber: sequenceNumber, + ItemID: currentReasoningID, + OutputIndex: &outputIndex, + ContentIndex: ¤tReasoningContentIndex, + Part: &reasoningPart, + }) + sequenceNumber++ + + // Emit output_item.done for reasoning + reasoningItem := &schema.ORItemField{ + Type: "reasoning", + ID: currentReasoningID, + Status: "completed", + Content: []schema.ORContentPart{reasoningPart}, + } + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_item.done", + SequenceNumber: sequenceNumber, + OutputIndex: &outputIndex, + Item: reasoningItem, + }) + sequenceNumber++ + + // Collect reasoning item for storage + collectedOutputItems = append(collectedOutputItems, *reasoningItem) + + // Calculate reasoning tokens + reasoningTokens = len(finalReasoning) / 4 + if reasoningTokens == 0 && len(finalReasoning) > 0 { + reasoningTokens = 1 + } + } + + cleanedResult := functions.CleanupLLMResult(finalCleanedResult, cfg.FunctionsConfig) xlog.Debug("Open Responses Stream - Cleaned result", "cleanedResult", cleanedResult) parsedToolCalls := functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig) @@ -1789,10 +1989,10 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 // Convert prediction logprobs for streaming events streamEventLogprobs := convertLogprobsForStreaming(prediction.Logprobs) - // If we have no output but the model did produce something, use the raw result - if textContent == "" && len(toolCalls) == 0 && result != "" { - xlog.Debug("Open Responses Stream - No parsed output, using raw result") - textContent = result + // If we have no output but the model did produce something, use the cleaned result (without reasoning tags) + if textContent == "" && len(toolCalls) == 0 && finalCleanedResult != "" { + xlog.Debug("Open Responses Stream - No parsed output, using cleaned result") + textContent = finalCleanedResult } // Close message if we have text content @@ -1875,8 +2075,18 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 collectedOutputItems = append(collectedOutputItems, *functionCallItem) } - // Build final response with all items (include logprobs) + // Build final response with all items (include reasoning first, then messages, then tool calls) var allOutputItems []schema.ORItemField + // Add reasoning item if it exists + if currentReasoningID != "" && finalReasoning != "" { + allOutputItems = append(allOutputItems, schema.ORItemField{ + Type: "reasoning", + ID: currentReasoningID, + Status: "completed", + Content: []schema.ORContentPart{makeOutputTextPart(finalReasoning)}, + }) + } + // Add message item if currentMessageID != "" && textContent != "" { allOutputItems = append(allOutputItems, schema.ORItemField{ Type: "message", @@ -1886,6 +2096,7 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(textContent, prediction.Logprobs)}, }) } + // Add tool call items for _, tc := range toolCalls { toolCallID := fmt.Sprintf("fc_%s", uuid.New().String()) allOutputItems = append(allOutputItems, schema.ORItemField{ @@ -1904,6 +2115,9 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 InputTokens: prediction.Usage.Prompt, OutputTokens: prediction.Usage.Completion, TotalTokens: prediction.Usage.Prompt + prediction.Usage.Completion, + OutputTokensDetails: &schema.OROutputTokensDetails{ + ReasoningTokens: reasoningTokens, + }, }, shouldStore) sendSSEEvent(c, &schema.ORStreamEvent{ @@ -1956,22 +2170,102 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 }) sequenceNumber++ - // Stream text deltas + // Stream text deltas with reasoning extraction tokenCallback := func(token string, tokenUsage backend.TokenUsage) bool { accumulatedText += token + accumulatedContent += token + // Prepend thinking token if needed, then extract reasoning + currentReasoning, cleanedContent := reason.ExtractReasoningWithConfig(accumulatedContent, thinkingStartToken, cfg.ReasoningConfig) - // Emit text delta - sendSSEEvent(c, &schema.ORStreamEvent{ - Type: "response.output_text.delta", - SequenceNumber: sequenceNumber, - ItemID: currentMessageID, - OutputIndex: &outputIndex, - ContentIndex: ¤tContentIndex, - Delta: strPtr(token), - Logprobs: emptyLogprobs(), - }) - sequenceNumber++ - c.Response().Flush() + // Handle reasoning item + if currentReasoning != "" { + // Check if we need to create reasoning item + if currentReasoningID == "" { + outputIndex++ + currentReasoningID = fmt.Sprintf("reasoning_%s", uuid.New().String()) + reasoningItem := &schema.ORItemField{ + Type: "reasoning", + ID: currentReasoningID, + Status: "in_progress", + } + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_item.added", + SequenceNumber: sequenceNumber, + OutputIndex: &outputIndex, + Item: reasoningItem, + }) + sequenceNumber++ + + // Emit content_part.added for reasoning + currentReasoningContentIndex = 0 + emptyPart := makeOutputTextPart("") + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.content_part.added", + SequenceNumber: sequenceNumber, + ItemID: currentReasoningID, + OutputIndex: &outputIndex, + ContentIndex: ¤tReasoningContentIndex, + Part: &emptyPart, + }) + sequenceNumber++ + } + + // Calculate reasoning delta + var reasoningDelta string + if len(currentReasoning) > len(lastEmittedReasoning) && strings.HasPrefix(currentReasoning, lastEmittedReasoning) { + reasoningDelta = currentReasoning[len(lastEmittedReasoning):] + lastEmittedReasoning = currentReasoning + } else if currentReasoning != lastEmittedReasoning { + reasoningDelta = currentReasoning + lastEmittedReasoning = currentReasoning + } + + // Emit reasoning delta if there's new content + if reasoningDelta != "" { + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_text.delta", + SequenceNumber: sequenceNumber, + ItemID: currentReasoningID, + OutputIndex: &outputIndex, + ContentIndex: ¤tReasoningContentIndex, + Delta: strPtr(reasoningDelta), + Logprobs: emptyLogprobs(), + }) + sequenceNumber++ + c.Response().Flush() + } + } + + // Handle message content (cleaned content without reasoning tags) + var deltaContent string + if len(cleanedContent) > len(lastEmittedCleanedContent) && strings.HasPrefix(cleanedContent, lastEmittedCleanedContent) { + deltaContent = cleanedContent[len(lastEmittedCleanedContent):] + lastEmittedCleanedContent = cleanedContent + } else if cleanedContent != lastEmittedCleanedContent { + if lastEmittedCleanedContent == "" { + deltaContent = cleanedContent + lastEmittedCleanedContent = cleanedContent + } else { + deltaContent = cleanedContent + lastEmittedCleanedContent = cleanedContent + } + } + + // Only emit message content if there's actual content (not just reasoning) + if deltaContent != "" { + // Emit text delta + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_text.delta", + SequenceNumber: sequenceNumber, + ItemID: currentMessageID, + OutputIndex: &outputIndex, + ContentIndex: ¤tContentIndex, + Delta: strPtr(deltaContent), + Logprobs: emptyLogprobs(), + }) + sequenceNumber++ + c.Response().Flush() + } return true } @@ -2034,6 +2328,62 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 result := backend.Finetune(*cfg, predInput, prediction.Response) + // Extract reasoning from final result for non-tool-call path + finalReasoning, finalCleanedResult := reason.ExtractReasoningWithConfig(result, thinkingStartToken, cfg.ReasoningConfig) + + // Close reasoning item if it exists and wasn't closed yet + if currentReasoningID != "" && finalReasoning != "" { + // Emit output_text.done for reasoning + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_text.done", + SequenceNumber: sequenceNumber, + ItemID: currentReasoningID, + OutputIndex: &outputIndex, + ContentIndex: ¤tReasoningContentIndex, + Text: strPtr(finalReasoning), + Logprobs: emptyLogprobs(), + }) + sequenceNumber++ + + // Emit content_part.done for reasoning + reasoningPart := makeOutputTextPart(finalReasoning) + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.content_part.done", + SequenceNumber: sequenceNumber, + ItemID: currentReasoningID, + OutputIndex: &outputIndex, + ContentIndex: ¤tReasoningContentIndex, + Part: &reasoningPart, + }) + sequenceNumber++ + + // Emit output_item.done for reasoning + reasoningItem := &schema.ORItemField{ + Type: "reasoning", + ID: currentReasoningID, + Status: "completed", + Content: []schema.ORContentPart{reasoningPart}, + } + sendSSEEvent(c, &schema.ORStreamEvent{ + Type: "response.output_item.done", + SequenceNumber: sequenceNumber, + OutputIndex: &outputIndex, + Item: reasoningItem, + }) + sequenceNumber++ + + // Collect reasoning item for storage + collectedOutputItems = append(collectedOutputItems, *reasoningItem) + + // Calculate reasoning tokens + reasoningTokens = len(finalReasoning) / 4 + if reasoningTokens == 0 && len(finalReasoning) > 0 { + reasoningTokens = 1 + } + } + + result = finalCleanedResult + // Convert prediction logprobs for streaming events mcpStreamLogprobs := convertLogprobsForStreaming(prediction.Logprobs) @@ -2075,17 +2425,35 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6 // Emit response.completed now := time.Now().Unix() - // Collect final output items (use collected items if available, otherwise use messageItem) + // Collect final output items (reasoning first, then message) var finalOutputItems []schema.ORItemField + // Add reasoning item if it exists + if currentReasoningID != "" && finalReasoning != "" { + finalOutputItems = append(finalOutputItems, schema.ORItemField{ + Type: "reasoning", + ID: currentReasoningID, + Status: "completed", + Content: []schema.ORContentPart{makeOutputTextPart(finalReasoning)}, + }) + } + // Add message item if len(collectedOutputItems) > 0 { - finalOutputItems = collectedOutputItems + // Use collected items (may include reasoning already) + for _, item := range collectedOutputItems { + if item.Type == "message" { + finalOutputItems = append(finalOutputItems, item) + } + } } else { - finalOutputItems = []schema.ORItemField{*messageItem} + finalOutputItems = append(finalOutputItems, *messageItem) } responseCompleted := buildORResponse(responseID, createdAt, &now, "completed", input, finalOutputItems, &schema.ORUsage{ InputTokens: prediction.Usage.Prompt, OutputTokens: prediction.Usage.Completion, TotalTokens: prediction.Usage.Prompt + prediction.Usage.Completion, + OutputTokensDetails: &schema.OROutputTokensDetails{ + ReasoningTokens: reasoningTokens, + }, }, shouldStore) sendSSEEvent(c, &schema.ORStreamEvent{ Type: "response.completed", diff --git a/core/schema/openresponses.go b/core/schema/openresponses.go index f6283ed97..b5a81f413 100644 --- a/core/schema/openresponses.go +++ b/core/schema/openresponses.go @@ -93,7 +93,12 @@ type ORItemParam struct { // Function call output fields Output interface{} `json:"output,omitempty"` // string or []ORContentPart + // Reasoning fields (for type == "reasoning") + Summary []ORContentPart `json:"summary,omitempty"` // Array of summary parts + EncryptedContent *string `json:"encrypted_content,omitempty"` // Provider-specific encrypted content + // Note: For item_reference type, use the ID field above to reference the item + // Note: For reasoning type, Content field (from message fields) contains the raw reasoning content } // ORContentPart represents a content block (discriminated union by type) diff --git a/docs/content/advanced/model-configuration.md b/docs/content/advanced/model-configuration.md index b4d3b3e9e..6796a354c 100644 --- a/docs/content/advanced/model-configuration.md +++ b/docs/content/advanced/model-configuration.md @@ -397,6 +397,83 @@ Agent/autonomous agent configuration: | `agent.enable_mcp_prompts` | bool | Enable MCP prompts | | `agent.enable_plan_re_evaluator` | bool | Enable plan re-evaluation | +## Reasoning Configuration + +Configure how reasoning tags are extracted and processed from model output. Reasoning tags are used by models like DeepSeek, Command-R, and others to include internal reasoning steps in their responses. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `reasoning.disable` | bool | `false` | When `true`, disables reasoning extraction entirely. The original content is returned without any processing. | +| `reasoning.disable_reasoning_tag_prefill` | bool | `false` | When `true`, disables automatic prepending of thinking start tokens. Use this when your model already includes reasoning tags in its output format. | +| `reasoning.strip_reasoning_only` | bool | `false` | When `true`, extracts and removes reasoning tags from content but discards the reasoning text. Useful when you want to clean reasoning tags from output without storing the reasoning content. | +| `reasoning.thinking_start_tokens` | array | `[]` | List of custom thinking start tokens to detect in prompts. Custom tokens are checked before default tokens. | +| `reasoning.tag_pairs` | array | `[]` | List of custom tag pairs for reasoning extraction. Each entry has `start` and `end` fields. Custom pairs are checked before default pairs. | + +### Reasoning Tag Formats + +The reasoning extraction supports multiple tag formats used by different models: + +- `...` - General thinking tag +- `...` - DeepSeek, Granite, ExaOne, GLM models +- `<|START_THINKING|>...<|END_THINKING|>` - Command-R models +- `<|inner_prefix|>...<|inner_suffix|>` - Apertus models +- `...` - Seed models +- `<|think|>...<|end|><|begin|>assistant<|content|>` - Solar Open models +- `[THINK]...[/THINK]` - Magistral models + +### Examples + +**Disable reasoning extraction:** +```yaml +reasoning: + disable: true +``` + +**Extract reasoning but don't prepend tags:** +```yaml +reasoning: + disable_reasoning_tag_prefill: true +``` + +**Strip reasoning tags without storing reasoning content:** +```yaml +reasoning: + strip_reasoning_only: true +``` + +**Complete example with reasoning configuration:** +```yaml +name: deepseek-model +backend: llama-cpp +parameters: + model: deepseek.gguf + +reasoning: + disable: false + disable_reasoning_tag_prefill: false + strip_reasoning_only: false +``` + +**Example with custom tokens and tag pairs:** +```yaml +name: custom-reasoning-model +backend: llama-cpp +parameters: + model: custom.gguf + +reasoning: + thinking_start_tokens: + - "" + - "" + tag_pairs: + - start: "" + end: "" + - start: "" + end: "" +``` + +**Note:** Custom tokens and tag pairs are checked before the default ones, giving them priority. This allows you to override default behavior or add support for new reasoning tag formats. + ## Pipeline Configuration Define pipelines for audio-to-audio processing: diff --git a/pkg/reasoning/config.go b/pkg/reasoning/config.go index 0fc23cc19..040be828d 100644 --- a/pkg/reasoning/config.go +++ b/pkg/reasoning/config.go @@ -1,5 +1,15 @@ package reasoning -type Config struct { - DisableReasoningTagPrefill *bool `yaml:"disable_reasoning_tag_prefill,omitempty" json:"disable_reasoning_tag_prefill,omitempty"` +// TagPair represents a start/end tag pair for reasoning extraction +type TagPair struct { + Start string `yaml:"start" json:"start"` + End string `yaml:"end" json:"end"` +} + +type Config struct { + DisableReasoningTagPrefill *bool `yaml:"disable_reasoning_tag_prefill,omitempty" json:"disable_reasoning_tag_prefill,omitempty"` + DisableReasoning *bool `yaml:"disable,omitempty" json:"disable,omitempty"` + StripReasoningOnly *bool `yaml:"strip_reasoning_only,omitempty" json:"strip_reasoning_only,omitempty"` + ThinkingStartTokens []string `yaml:"thinking_start_tokens,omitempty" json:"thinking_start_tokens,omitempty"` + TagPairs []TagPair `yaml:"tag_pairs,omitempty" json:"tag_pairs,omitempty"` } diff --git a/pkg/reasoning/reasoning.go b/pkg/reasoning/reasoning.go index 6add81e75..b61b2ea1d 100644 --- a/pkg/reasoning/reasoning.go +++ b/pkg/reasoning/reasoning.go @@ -17,12 +17,12 @@ import ( // - (DeepSeek, Granite, ExaOne models) // - <|think|> (Solar Open models) // - (General thinking tag) -// - (GLM models) // - [THINK] (Magistral models) -func DetectThinkingStartToken(prompt string) string { +// Custom tokens from config are checked first, then default tokens. +func DetectThinkingStartToken(prompt string, config *Config) string { // Common thinking start tokens (in order of specificity - longer first) // Based on llama.cpp's chat-parser.cpp implementations - thinkingStartTokens := []string{ + defaultTokens := []string{ "<|START_THINKING|>", // Command-R models "<|inner_prefix|>", // Apertus models "", // Seed models @@ -32,6 +32,13 @@ func DetectThinkingStartToken(prompt string) string { "[THINK]", // Magistral models } + // Merge custom tokens with default tokens (custom tokens first for priority) + var thinkingStartTokens []string + if config != nil && len(config.ThinkingStartTokens) > 0 { + thinkingStartTokens = append(thinkingStartTokens, config.ThinkingStartTokens...) + } + thinkingStartTokens = append(thinkingStartTokens, defaultTokens...) + // Check if prompt ends with any of these tokens (allowing for trailing whitespace/newlines) trimmedPrompt := strings.TrimRight(prompt, " \t\n\r") for _, token := range thinkingStartTokens { @@ -58,6 +65,28 @@ func DetectThinkingStartToken(prompt string) string { return "" } +// ExtractReasoningWithConfig extracts reasoning from content with the given config. +// If reasoning is disabled, it returns the original content. +// If thinking start token prefill is enabled, it prepends the thinking start token to the content. +// It returns the extracted reasoning and the cleaned content. +func ExtractReasoningWithConfig(content, thinkingStartToken string, config Config) (reasoning string, cleanedContent string) { + cleanedContent = content + // If reasoning is not disabled, prepend the thinking start token if needed and extract reasoning + if config.DisableReasoning == nil || !*config.DisableReasoning { + // If thinking start token prefill is not disabled, prepend the thinking start token + if config.DisableReasoningTagPrefill == nil || !*config.DisableReasoningTagPrefill { + cleanedContent = PrependThinkingTokenIfNeeded(cleanedContent, thinkingStartToken) + } + // Extract reasoning from the cleaned content + reasoning, cleanedContent = ExtractReasoning(cleanedContent, &config) + if config.StripReasoningOnly != nil && *config.StripReasoningOnly { + reasoning = "" + } + } + + return reasoning, cleanedContent +} + // PrependThinkingTokenIfNeeded prepends the thinking start token to content if it was // detected in the prompt. This allows the standard extraction logic to work correctly // for models where the thinking token is already in the prompt. @@ -97,7 +126,8 @@ func PrependThinkingTokenIfNeeded(content string, startToken string) string { // 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) { +// Custom tag pairs from config are checked first, then default tag pairs. +func ExtractReasoning(content string, config *Config) (reasoning string, cleanedContent string) { if content == "" { return "", content } @@ -106,8 +136,8 @@ func ExtractReasoning(content string) (reasoning string, cleanedContent string) var cleanedParts []string remaining := content - // Define tag pairs to look for (matching llama.cpp's chat-parser.cpp) - tagPairs := []struct { + // Define default tag pairs to look for (matching llama.cpp's chat-parser.cpp) + defaultTagPairs := []struct { start string end string }{ @@ -120,6 +150,26 @@ func ExtractReasoning(content string) (reasoning string, cleanedContent string) {"[THINK]", "[/THINK]"}, // Magistral models } + // Merge custom tag pairs with default tag pairs (custom pairs first for priority) + var tagPairs []struct { + start string + end string + } + if config != nil && len(config.TagPairs) > 0 { + for _, pair := range config.TagPairs { + if pair.Start != "" && pair.End != "" { + tagPairs = append(tagPairs, struct { + start string + end string + }{pair.Start, pair.End}) + } + } + } + // Add default tag pairs + for _, pair := range defaultTagPairs { + tagPairs = append(tagPairs, pair) + } + // Track the last position we've processed lastPos := 0 diff --git a/pkg/reasoning/reasoning_test.go b/pkg/reasoning/reasoning_test.go index f66eca55e..290576b7e 100644 --- a/pkg/reasoning/reasoning_test.go +++ b/pkg/reasoning/reasoning_test.go @@ -12,21 +12,21 @@ var _ = Describe("ExtractReasoning", func() { 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, nil) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal(content)) }) It("should handle empty string", func() { content := "" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) 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, nil) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal(content)) }) @@ -35,42 +35,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, nil) 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, nil) 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, nil) 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, nil) 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, nil) 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, nil) Expect(reasoning).To(Equal("Reasoning with spaces")) Expect(cleaned).To(Equal("Text More")) }) @@ -79,21 +79,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, nil) 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, nil) 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, nil) Expect(reasoning).To(Equal("Complex reasoning\nwith\nmultiple\nlines")) Expect(cleaned).To(Equal("Start End")) }) @@ -102,14 +102,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, nil) 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, nil) Expect(reasoning).To(ContainSubstring("One")) Expect(reasoning).To(ContainSubstring("Two")) Expect(reasoning).To(ContainSubstring("Three")) @@ -118,7 +118,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, nil) // Should extract the outer thinking block Expect(reasoning).To(ContainSubstring("Outer")) Expect(reasoning).To(ContainSubstring("Inner")) @@ -129,28 +129,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, nil) 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, nil) 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, nil) 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, nil) Expect(reasoning).To(Equal("Unclosed at end")) Expect(cleaned).To(Equal("Regular content ")) }) @@ -159,14 +159,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, nil) 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, nil) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal("Text More")) }) @@ -175,28 +175,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, nil) 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, nil) 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, nil) 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, nil) Expect(reasoning).To(Equal("Reasoning with inside")) Expect(cleaned).To(Equal("Text More")) }) @@ -205,7 +205,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, nil) Expect(reasoning).To(ContainSubstring("Reasoning")) Expect(reasoning).To(ContainSubstring("More reasoning")) Expect(cleaned).To(Equal("Start Middle End")) @@ -213,7 +213,7 @@ 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, nil) Expect(reasoning).To(Equal("reasoning")) Expect(cleaned).To(Equal("This is a sentence.")) }) @@ -222,21 +222,21 @@ var _ = Describe("ExtractReasoning", func() { Context("edge cases", func() { It("should handle content with only opening tag", func() { content := "" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal("")) }) It("should handle content with only closing tag", func() { content := "" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) Expect(reasoning).To(BeEmpty()) Expect(cleaned).To(Equal("")) }) It("should handle mismatched tags", func() { content := "Content" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) // Should extract unclosed thinking block Expect(reasoning).To(ContainSubstring("Content")) Expect(cleaned).To(Equal("")) @@ -245,7 +245,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, nil) // TrimSpace is applied, so we need to account for that Expect(reasoning).To(Equal(strings.TrimSpace(longReasoning))) Expect(cleaned).To(Equal("Text More")) @@ -253,7 +253,7 @@ 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, nil) Expect(reasoning).To(Equal("Reasoning with 中文 and emoji 🧠")) Expect(cleaned).To(Equal("Text More")) }) @@ -262,14 +262,14 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has <|START_THINKING|> tags (Command-R)", func() { It("should extract reasoning from START_THINKING block", func() { content := "Text <|START_THINKING|>Command-R reasoning<|END_THINKING|> More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) Expect(reasoning).To(Equal("Command-R reasoning")) Expect(cleaned).To(Equal("Text More")) }) It("should handle unclosed START_THINKING block", func() { content := "Before <|START_THINKING|>Incomplete reasoning" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) Expect(reasoning).To(Equal("Incomplete reasoning")) Expect(cleaned).To(Equal("Before ")) }) @@ -278,7 +278,7 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has <|inner_prefix|> tags (Apertus)", func() { It("should extract reasoning from inner_prefix block", func() { content := "Text <|inner_prefix|>Apertus reasoning<|inner_suffix|> More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) Expect(reasoning).To(Equal("Apertus reasoning")) Expect(cleaned).To(Equal("Text More")) }) @@ -287,7 +287,7 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has tags (Seed)", func() { It("should extract reasoning from seed:think block", func() { content := "Text Seed reasoning More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) Expect(reasoning).To(Equal("Seed reasoning")) Expect(cleaned).To(Equal("Text More")) }) @@ -296,7 +296,7 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has <|think|> tags (Solar Open)", func() { It("should extract reasoning from Solar Open think block", func() { content := "Text <|think|>Solar reasoning<|end|><|begin|>assistant<|content|> More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) Expect(reasoning).To(Equal("Solar reasoning")) Expect(cleaned).To(Equal("Text More")) }) @@ -305,14 +305,14 @@ var _ = Describe("ExtractReasoning", func() { Context("when content has [THINK] tags (Magistral)", func() { It("should extract reasoning from THINK block", func() { content := "Text [THINK]Magistral reasoning[/THINK] More" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) Expect(reasoning).To(Equal("Magistral reasoning")) Expect(cleaned).To(Equal("Text More")) }) It("should handle unclosed THINK block", func() { content := "Before [THINK]Incomplete reasoning" - reasoning, cleaned := ExtractReasoning(content) + reasoning, cleaned := ExtractReasoning(content, nil) Expect(reasoning).To(Equal("Incomplete reasoning")) Expect(cleaned).To(Equal("Before ")) }) @@ -323,62 +323,62 @@ var _ = Describe("DetectThinkingStartToken", func() { Context("when prompt contains thinking start tokens", func() { It("should detect <|START_THINKING|> at the end", func() { prompt := "Some prompt text <|START_THINKING|>" - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("<|START_THINKING|>")) }) It("should detect at the end", func() { prompt := "Prompt with " - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("")) }) It("should detect at the end", func() { prompt := "Some text " - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("")) }) It("should detect <|inner_prefix|> at the end", func() { prompt := "Prompt <|inner_prefix|>" - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("<|inner_prefix|>")) }) It("should detect at the end", func() { prompt := "Text " - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("")) }) It("should detect <|think|> at the end", func() { prompt := "Prompt <|think|>" - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("<|think|>")) }) It("should detect [THINK] at the end", func() { prompt := "Text [THINK]" - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("[THINK]")) }) It("should handle trailing whitespace", func() { prompt := "Prompt <|START_THINKING|> \n\t " - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("<|START_THINKING|>")) }) It("should detect token near the end (within last 100 chars)", func() { prefix := strings.Repeat("x", 50) prompt := prefix + "<|START_THINKING|>" - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("<|START_THINKING|>")) }) It("should detect token when followed by only whitespace", func() { prompt := "Text \n " - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(Equal("")) }) }) @@ -386,27 +386,27 @@ var _ = Describe("DetectThinkingStartToken", func() { Context("when prompt does not contain thinking tokens", func() { It("should return empty string for regular prompt", func() { prompt := "This is a regular prompt without thinking tokens" - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(BeEmpty()) }) It("should return empty string for empty prompt", func() { prompt := "" - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) Expect(token).To(BeEmpty()) }) It("should detect token even when far from end (Contains check)", func() { prefix := strings.Repeat("x", 150) prompt := prefix + "<|START_THINKING|>" - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) // Current implementation uses Contains, so it finds tokens anywhere Expect(token).To(Equal("<|START_THINKING|>")) }) It("should detect token even when followed by non-whitespace (Contains check)", func() { prompt := "Text <|START_THINKING|>more text" - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) // Current implementation uses Contains, so it finds tokens anywhere Expect(token).To(Equal("<|START_THINKING|>")) }) @@ -415,7 +415,7 @@ var _ = Describe("DetectThinkingStartToken", func() { Context("when multiple tokens are present", func() { It("should return the first matching token (most specific)", func() { prompt := "Text <|START_THINKING|> " - token := DetectThinkingStartToken(prompt) + token := DetectThinkingStartToken(prompt, nil) // Should return the first one found (order matters) Expect(token).To(Equal("<|START_THINKING|>")) }) @@ -504,3 +504,641 @@ var _ = Describe("PrependThinkingTokenIfNeeded", func() { }) }) }) + +var _ = Describe("ExtractReasoningWithConfig", func() { + Context("when reasoning is disabled", func() { + It("should return original content when DisableReasoning is true", func() { + content := "Some text Reasoning More text" + config := Config{DisableReasoning: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + + It("should return original content even with thinking start token when DisableReasoning is true", func() { + content := "Reasoning content" + config := Config{DisableReasoning: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "<|START_THINKING|>", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + + It("should return original content even with tag prefill disabled when DisableReasoning is true", func() { + content := "Some content" + config := Config{ + DisableReasoning: boolPtr(true), + DisableReasoningTagPrefill: boolPtr(false), + } + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + }) + + Context("when reasoning is enabled (DisableReasoning is nil or false)", func() { + Context("when tag prefill is enabled (DisableReasoningTagPrefill is nil or false)", func() { + It("should prepend token and extract reasoning when both configs are nil", func() { + content := "Reasoning content" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + // Token is prepended, then extracted + Expect(reasoning).To(Equal("Reasoning content")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should prepend token and extract reasoning when DisableReasoning is false", func() { + content := "Some reasoning" + config := Config{DisableReasoning: boolPtr(false)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Some reasoning")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should prepend token and extract reasoning when DisableReasoningTagPrefill is false", func() { + content := "My reasoning" + config := Config{DisableReasoningTagPrefill: boolPtr(false)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "<|START_THINKING|>", config) + Expect(reasoning).To(Equal("My reasoning")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should prepend token to content with existing tags and extract", func() { + content := "Before Existing reasoning After" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + // Should extract existing reasoning, token prepend doesn't affect already tagged content + Expect(reasoning).To(Equal("Existing reasoning")) + Expect(cleaned).To(Equal("Before After")) + }) + + It("should prepend token and extract from content that becomes tagged", func() { + content := "Pure reasoning without tags" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + // Token is prepended, making it Pure reasoning without tags + // But since there's no closing tag, it extracts as unclosed + Expect(reasoning).To(Equal("Pure reasoning without tags")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should handle empty token when tag prefill is enabled", func() { + content := "Some content Reasoning More" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + // No token to prepend, just extract existing reasoning + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Some content More")) + }) + + It("should prepend token after leading whitespace", func() { + content := " \n Reasoning content" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Reasoning content")) + Expect(cleaned).To(Equal(" \n ")) + }) + }) + + Context("when tag prefill is disabled (DisableReasoningTagPrefill is true)", func() { + It("should extract reasoning without prepending token when DisableReasoningTagPrefill is true", func() { + content := "Some text Reasoning More text" + config := Config{DisableReasoningTagPrefill: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Some text More text")) + }) + + It("should not prepend token to content without tags when DisableReasoningTagPrefill is true", func() { + content := "Pure content without tags" + config := Config{DisableReasoningTagPrefill: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + // No token prepended, no tags to extract + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + + It("should extract multiple reasoning blocks without prepending when DisableReasoningTagPrefill is true", func() { + content := "A First B Second C" + config := Config{DisableReasoningTagPrefill: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(ContainSubstring("First")) + Expect(reasoning).To(ContainSubstring("Second")) + Expect(cleaned).To(Equal("A B C")) + }) + + It("should handle empty token when tag prefill is disabled", func() { + content := "Text Reasoning More" + config := Config{DisableReasoningTagPrefill: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + }) + }) + + Context("edge cases", func() { + It("should handle empty content with default config", func() { + content := "" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(BeEmpty()) + }) + + It("should handle empty content when reasoning is disabled", func() { + content := "" + config := Config{DisableReasoning: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(BeEmpty()) + }) + + It("should handle empty token with content containing tags", func() { + content := "Text Reasoning More" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should handle content with only whitespace when reasoning is enabled", func() { + content := " \n\t " + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + // Token is prepended after whitespace, then extracted as unclosed + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(" \n\t ")) + }) + + It("should handle content with only whitespace when reasoning is disabled", func() { + content := " \n\t " + config := Config{DisableReasoning: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + + It("should handle unclosed reasoning tags with tag prefill enabled", func() { + content := "Some text Unclosed" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Unclosed")) + Expect(cleaned).To(Equal("Some text ")) + }) + + It("should handle different token types with config", func() { + content := "Reasoning content" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "<|START_THINKING|>", config) + Expect(reasoning).To(Equal("Reasoning content")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should handle content that already contains the token", func() { + content := "Already tagged" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + // Token already present, should not prepend, just extract + Expect(reasoning).To(Equal("Already tagged")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should handle complex reasoning with multiline content and tag prefill", func() { + content := "Before\nLine 1\nLine 2\nLine 3\nAfter" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3")) + Expect(cleaned).To(Equal("Before\n\nAfter")) + }) + }) + + Context("config combinations", func() { + It("should handle nil DisableReasoning and nil DisableReasoningTagPrefill", func() { + content := "Reasoning" + config := Config{} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should handle false DisableReasoning and true DisableReasoningTagPrefill", func() { + content := "Text Reasoning More" + config := Config{ + DisableReasoning: boolPtr(false), + DisableReasoningTagPrefill: boolPtr(true), + } + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should handle true DisableReasoning regardless of DisableReasoningTagPrefill", func() { + content := "Some content Reasoning" + config := Config{ + DisableReasoning: boolPtr(true), + DisableReasoningTagPrefill: boolPtr(false), + } + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + }) + + Context("when StripReasoningOnly is enabled", func() { + It("should strip reasoning but keep cleaned content when StripReasoningOnly is true", func() { + content := "Some text Reasoning content More text" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Some text More text")) + }) + + It("should strip reasoning from multiple blocks when StripReasoningOnly is true", func() { + content := "A First B Second C" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("A B C")) + }) + + It("should strip reasoning from different tag types when StripReasoningOnly is true", func() { + content := "Before One Middle Two After" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Before Middle After")) + }) + + It("should strip reasoning but preserve content when StripReasoningOnly is true", func() { + content := "Regular content Reasoning" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Regular content ")) + }) + + It("should strip reasoning from unclosed tags when StripReasoningOnly is true", func() { + content := "Text Unclosed reasoning" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Text ")) + }) + + It("should strip reasoning from Command-R tags when StripReasoningOnly is true", func() { + content := "Before <|START_THINKING|>Command-R reasoning<|END_THINKING|> After" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "<|START_THINKING|>", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Before After")) + }) + + It("should strip reasoning from Apertus tags when StripReasoningOnly is true", func() { + content := "Text <|inner_prefix|>Apertus reasoning<|inner_suffix|> More" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "<|inner_prefix|>", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should strip reasoning from Seed tags when StripReasoningOnly is true", func() { + content := "Before Seed reasoning After" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Before After")) + }) + + It("should strip reasoning from Magistral tags when StripReasoningOnly is true", func() { + content := "Text [THINK]Magistral reasoning[/THINK] More" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "[THINK]", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should strip reasoning with multiline content when StripReasoningOnly is true", func() { + content := "Start Line 1\nLine 2\nLine 3 End" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Start End")) + }) + + It("should handle content with only reasoning tags when StripReasoningOnly is true", func() { + content := "Only reasoning" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(BeEmpty()) + }) + + It("should handle empty reasoning blocks when StripReasoningOnly is true", func() { + content := "Text More" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should handle content without reasoning tags when StripReasoningOnly is true", func() { + content := "Regular content without tags" + config := Config{ + StripReasoningOnly: boolPtr(true), + DisableReasoningTagPrefill: boolPtr(true), + } + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + + It("should handle content without reasoning tags when StripReasoningOnly is true", func() { + content := "Regular content without tags" + config := Config{ + StripReasoningOnly: boolPtr(true), + } + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + + It("should handle content without reasoning tags when StripReasoningOnly is true", func() { + content := "Regular content without tags" + config := Config{ + StripReasoningOnly: boolPtr(true), + } + thinkingStartToken := DetectThinkingStartToken("template_without_thinking_tag", &config) + reasoning, cleaned := ExtractReasoningWithConfig(content, thinkingStartToken, config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + + It("should handle content without reasoning tags when StripReasoningOnly is true", func() { + content := "fooRegular content without tags" + config := Config{} + thinkingStartToken := DetectThinkingStartToken("", &config) + reasoning, cleaned := ExtractReasoningWithConfig(content, thinkingStartToken, config) + Expect(reasoning).To(Equal("foo")) + Expect(cleaned).To(Equal("Regular content without tags")) + }) + + It("should strip reasoning when StripReasoningOnly is true and tag prefill is enabled", func() { + content := "Reasoning content" + config := Config{ + StripReasoningOnly: boolPtr(true), + DisableReasoningTagPrefill: boolPtr(false), + } + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(BeEmpty()) + }) + + It("should strip reasoning when StripReasoningOnly is true and tag prefill is disabled", func() { + content := "Text Reasoning More" + config := Config{ + StripReasoningOnly: boolPtr(true), + DisableReasoningTagPrefill: boolPtr(true), + } + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should not strip reasoning when StripReasoningOnly is false", func() { + content := "Text Reasoning More" + config := Config{StripReasoningOnly: boolPtr(false)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should not strip reasoning when StripReasoningOnly is nil", func() { + content := "Text Reasoning More" + config := Config{StripReasoningOnly: nil} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should strip reasoning but not affect DisableReasoning behavior", func() { + content := "Text Reasoning More" + config := Config{ + DisableReasoning: boolPtr(true), + StripReasoningOnly: boolPtr(true), + } + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + // When DisableReasoning is true, reasoning extraction doesn't happen at all + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal(content)) + }) + + It("should handle complex content with reasoning and regular text when StripReasoningOnly is true", func() { + content := "Start Reasoning Middle More reasoning End" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Start Middle End")) + }) + + It("should handle reasoning with special characters when StripReasoningOnly is true", func() { + content := "Before Reasoning with ```code``` and {\"json\": true} After" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Before After")) + }) + + It("should handle reasoning with unicode when StripReasoningOnly is true", func() { + content := "Text Reasoning with 中文 and emoji 🧠 More" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Text More")) + }) + }) +}) + +var _ = Describe("Custom Thinking Start Tokens", func() { + Context("when custom thinking start tokens are provided", func() { + It("should detect custom thinking start token", func() { + prompt := "Some prompt " + config := &Config{ThinkingStartTokens: []string{""}} + token := DetectThinkingStartToken(prompt, config) + Expect(token).To(Equal("")) + }) + + It("should prioritize custom tokens over default tokens", func() { + prompt := "Text " + config := &Config{ThinkingStartTokens: []string{""}} + token := DetectThinkingStartToken(prompt, config) + // Custom token should be found first even if default token appears later + Expect(token).To(Equal("")) + }) + + It("should detect multiple custom tokens (first match)", func() { + prompt := "Prompt " + config := &Config{ThinkingStartTokens: []string{"", ""}} + token := DetectThinkingStartToken(prompt, config) + Expect(token).To(Equal("")) + }) + + It("should fall back to default tokens if custom tokens not found", func() { + prompt := "Text " + config := &Config{ThinkingStartTokens: []string{""}} + token := DetectThinkingStartToken(prompt, config) + Expect(token).To(Equal("")) + }) + + It("should handle empty custom tokens list", func() { + prompt := "Text " + config := &Config{ThinkingStartTokens: []string{}} + token := DetectThinkingStartToken(prompt, config) + Expect(token).To(Equal("")) + }) + + It("should handle nil config (use defaults only)", func() { + prompt := "Text " + token := DetectThinkingStartToken(prompt, nil) + Expect(token).To(Equal("")) + }) + }) +}) + +var _ = Describe("Custom Tag Pairs", func() { + Context("when custom tag pairs are provided", func() { + It("should extract reasoning from custom tag pair", func() { + content := "Text Custom reasoning More" + config := &Config{TagPairs: []TagPair{{Start: "", End: ""}}} + reasoning, cleaned := ExtractReasoning(content, config) + Expect(reasoning).To(Equal("Custom reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should prioritize custom tag pairs over default pairs", func() { + content := "Text Custom Default More" + config := &Config{TagPairs: []TagPair{{Start: "", End: ""}}} + reasoning, cleaned := ExtractReasoning(content, config) + // Should extract both, but custom comes first + Expect(reasoning).To(ContainSubstring("Custom")) + Expect(reasoning).To(ContainSubstring("Default")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should handle multiple custom tag pairs", func() { + content := "A First B Second C" + config := &Config{ + TagPairs: []TagPair{ + {Start: "", End: ""}, + {Start: "", End: ""}, + }, + } + reasoning, cleaned := ExtractReasoning(content, config) + Expect(reasoning).To(ContainSubstring("First")) + Expect(reasoning).To(ContainSubstring("Second")) + Expect(cleaned).To(Equal("A B C")) + }) + + It("should handle custom tag pairs with complex end tags", func() { + content := "Text Reasoningassistant More" + config := &Config{TagPairs: []TagPair{{Start: "", End: "assistant"}}} + reasoning, cleaned := ExtractReasoning(content, config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should handle unclosed custom tag pairs", func() { + content := "Text Unclosed reasoning" + config := &Config{TagPairs: []TagPair{{Start: "", End: ""}}} + reasoning, cleaned := ExtractReasoning(content, config) + Expect(reasoning).To(Equal("Unclosed reasoning")) + Expect(cleaned).To(Equal("Text ")) + }) + + It("should ignore invalid tag pairs (empty start or end)", func() { + content := "Text Content More" + config := &Config{ + TagPairs: []TagPair{ + {Start: "", End: ""}, // Invalid: empty start + {Start: "", End: ""}, // Invalid: empty end + {Start: "", End: ""}, // Valid + }, + } + reasoning, cleaned := ExtractReasoning(content, config) + Expect(reasoning).To(Equal("Content")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should fall back to default tag pairs if custom pairs not found", func() { + content := "Text Default reasoning More" + config := &Config{TagPairs: []TagPair{{Start: "", End: ""}}} + reasoning, cleaned := ExtractReasoning(content, config) + Expect(reasoning).To(Equal("Default reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should handle empty custom tag pairs list", func() { + content := "Text Reasoning More" + config := &Config{TagPairs: []TagPair{}} + reasoning, cleaned := ExtractReasoning(content, config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should handle nil config (use defaults only)", func() { + content := "Text Reasoning More" + reasoning, cleaned := ExtractReasoning(content, nil) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should handle custom tag pairs with special characters", func() { + content := "Text <[think]>Reasoning More" + config := &Config{TagPairs: []TagPair{{Start: "<[think]>", End: ""}}} + reasoning, cleaned := ExtractReasoning(content, config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + + It("should handle custom tag pairs with multiline content", func() { + content := "Before Line 1\nLine 2\nLine 3 After" + config := &Config{TagPairs: []TagPair{{Start: "", End: ""}}} + reasoning, cleaned := ExtractReasoning(content, config) + Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3")) + Expect(cleaned).To(Equal("Before After")) + }) + }) +}) + +var _ = Describe("Custom Tokens and Tag Pairs Integration", func() { + Context("when both custom tokens and tag pairs are provided", func() { + It("should use custom thinking start token and custom tag pair together", func() { + content := "Reasoning content" + config := Config{ + ThinkingStartTokens: []string{""}, + TagPairs: []TagPair{{Start: "", End: ""}}, + } + // First detect the token + token := DetectThinkingStartToken("Prompt ", &config) + Expect(token).To(Equal("")) + // Then extract with the custom tag pair + reasoning, cleaned := ExtractReasoningWithConfig(content, token, config) + Expect(reasoning).To(Equal("Reasoning content")) + Expect(cleaned).To(BeEmpty()) + }) + + It("should work with ExtractReasoningWithConfig and custom config", func() { + content := "Text Reasoning More" + config := Config{ + TagPairs: []TagPair{{Start: "", End: ""}}, + } + reasoning, cleaned := ExtractReasoningWithConfig(content, "", config) + Expect(reasoning).To(Equal("Reasoning")) + Expect(cleaned).To(Equal("Text More")) + }) + }) +}) + +// Helper function to create bool pointers for test configs +func boolPtr(b bool) *bool { + return &b +}