fix(openaicompat): parse "reasoning" field and include reasoning_content on tool call messages (#196)

carsonfarmer and Claude Opus 4.6 (1M context) created

Some OpenAI-compatible providers (e.g. Moonshot AI/Kimi) send reasoning
content under the "reasoning" JSON field rather than "reasoning_content".
The ReasoningData struct only looked for "reasoning_content", silently
dropping all reasoning from these providers.

Additionally, when thinking is enabled, providers like Kimi require
reasoning_content to be present on all assistant tool call messages,
even if empty. Without this, multi-turn conversations with tool calls
fail with "thinking is enabled but reasoning_content is missing".

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

providers/openaicompat/language_model_hooks.go | 17 ++++--
providers/openaicompat/openaicompat_test.go    | 55 ++++++++++++++++++++
providers/openaicompat/provider_options.go     | 10 +++
3 files changed, 76 insertions(+), 6 deletions(-)

Detailed changes

providers/openaicompat/language_model_hooks.go 🔗

@@ -58,9 +58,9 @@ func ExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
 	if err != nil {
 		return content
 	}
-	if reasoningData.ReasoningContent != "" {
+	if rc := reasoningData.GetReasoningContent(); rc != "" {
 		content = append(content, fantasy.ReasoningContent{
-			Text: reasoningData.ReasoningContent,
+			Text: rc,
 		})
 	}
 	return content
@@ -114,11 +114,11 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str
 				Delta: reasoningContent,
 			})
 		}
-		if reasoningData.ReasoningContent != "" {
+		if rc := reasoningData.GetReasoningContent(); rc != "" {
 			if !reasoningStarted {
 				ctx[reasoningStartedCtx] = true
 			}
-			return ctx, emitEvent(reasoningData.ReasoningContent)
+			return ctx, emitEvent(rc)
 		}
 		if reasoningStarted && (choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0) {
 			ctx[reasoningStartedCtx] = false
@@ -137,6 +137,8 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str
 func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
 	var messages []openaisdk.ChatCompletionMessageParamUnion
 	var warnings []fantasy.CallWarning
+	hasReasoning := false
+
 	for _, msg := range prompt {
 		switch msg.Role {
 		case fantasy.MessageRoleSystem:
@@ -348,6 +350,7 @@ func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletio
 						continue
 					}
 					reasoningText = reasoningPart.Text
+					hasReasoning = true
 				case fantasy.ContentTypeToolCall:
 					toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
 					if !ok {
@@ -370,8 +373,10 @@ func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletio
 						})
 				}
 			}
-			// Add reasoning_content field if present
-			if reasoningText != "" {
+			// Add reasoning_content field if present, or if thinking is enabled
+			// and the message has tool calls (some providers like Kimi require
+			// reasoning_content on all assistant messages when thinking is enabled).
+			if reasoningText != "" || (hasReasoning && len(assistantMsg.ToolCalls) > 0) {
 				assistantMsg.SetExtraFields(map[string]any{
 					"reasoning_content": reasoningText,
 				})

providers/openaicompat/openaicompat_test.go 🔗

@@ -347,6 +347,61 @@ func TestToPromptFunc_DropsEmptyMessages(t *testing.T) {
 		require.Empty(t, warnings)
 	})
 
+	t.Run("should add empty reasoning_content to tool call messages when thinking is enabled", func(t *testing.T) {
+		t.Parallel()
+
+		// When thinking is enabled (reasoning parts exist in history),
+		// tool call messages without their own reasoning must still include
+		// reasoning_content. Providers like Kimi require it.
+		prompt := fantasy.Prompt{
+			{
+				Role: fantasy.MessageRoleUser,
+				Content: []fantasy.MessagePart{
+					fantasy.TextPart{Text: "What is 2+2?"},
+				},
+			},
+			{
+				// First turn has reasoning
+				Role: fantasy.MessageRoleAssistant,
+				Content: []fantasy.MessagePart{
+					fantasy.ReasoningPart{Text: "Simple math."},
+					fantasy.TextPart{Text: "Four"},
+				},
+			},
+			{
+				Role: fantasy.MessageRoleUser,
+				Content: []fantasy.MessagePart{
+					fantasy.TextPart{Text: "Now try a tool call"},
+				},
+			},
+			{
+				// Tool call WITHOUT reasoning on this turn
+				Role: fantasy.MessageRoleAssistant,
+				Content: []fantasy.MessagePart{
+					fantasy.ToolCallPart{
+						ToolCallID: "call_1",
+						ToolName:   "execute",
+						Input:      `{"command":"echo 4"}`,
+					},
+				},
+			},
+		}
+
+		messages, warnings := ToPromptFunc(prompt, "", "")
+
+		require.Empty(t, warnings)
+		require.Len(t, messages, 4)
+
+		// Tool call message must have reasoning_content (empty) since
+		// thinking is enabled in this conversation
+		msg := messages[3].OfAssistant
+		require.NotNil(t, msg)
+		extraFields := msg.ExtraFields()
+		reasoningContent, hasReasoning := extraFields["reasoning_content"]
+		require.True(t, hasReasoning, "reasoning_content must be present on tool call messages when thinking is enabled")
+		require.Equal(t, "", reasoningContent)
+	})
+
 	t.Run("should drop user messages without visible content", func(t *testing.T) {
 		t.Parallel()
 

providers/openaicompat/provider_options.go 🔗

@@ -31,8 +31,18 @@ type ProviderOptions struct {
 }
 
 // ReasoningData represents reasoning data for OpenAI-compatible provider.
+// Some providers use "reasoning_content" (e.g. Avian), others use "reasoning" (e.g. Moonshot AI/Kimi).
 type ReasoningData struct {
 	ReasoningContent string `json:"reasoning_content"`
+	Reasoning        string `json:"reasoning"`
+}
+
+// GetReasoningContent returns the reasoning text from whichever field is populated.
+func (r ReasoningData) GetReasoningContent() string {
+	if r.ReasoningContent != "" {
+		return r.ReasoningContent
+	}
+	return r.Reasoning
 }
 
 // Options implements the ProviderOptions interface.