diff --git a/providers/openaicompat/language_model_hooks.go b/providers/openaicompat/language_model_hooks.go index b43cb4eea11e7598ab2d81507b5cab603a5c42fd..e6018b48917187a4bae6c1f81b74ae8cffaa1acf 100644 --- a/providers/openaicompat/language_model_hooks.go +++ b/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, }) diff --git a/providers/openaicompat/openaicompat_test.go b/providers/openaicompat/openaicompat_test.go index a603e5e6681f3d06586e78b0c9efce4e9d7fc10d..c48eb087d949227b336f950708c75b859a782d6c 100644 --- a/providers/openaicompat/openaicompat_test.go +++ b/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() diff --git a/providers/openaicompat/provider_options.go b/providers/openaicompat/provider_options.go index afb037bf21e51d8698e4b51bc6f85a9ff99f242b..561860c32dabf176e820af5b3cc4980bf323abca 100644 --- a/providers/openaicompat/provider_options.go +++ b/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.