fix kimi empty assistant messages
- commit
- 0f332faf6639735d6cd8a59ed90ac5276a0ea38a
- author
- patriceckhart · · 2026-05-06 16:49 UTC
- parent
- f37f42b
- signature
- unsigned
- refs
- tag: v0.1.60
2 files · +34 -0
M internal/provider/openai.go +11 -0
| @@ -195,6 +195,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 195 | 195 | for _, b := range msg.Content { | |
| 196 | 196 | switch v := b.(type) { | |
| 197 | 197 | case TextBlock: | |
| 198 | + | if strings.TrimSpace(v.Text) == "" { | |
| 199 | + | continue | |
| 200 | + | } | |
| 198 | 201 | if text.Len() > 0 { | |
| 199 | 202 | text.WriteString("\n") | |
| 200 | 203 | } | |
| @@ -227,6 +230,14 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 227 | 230 | if reasoning.Len() > 0 && len(am.ToolCalls) > 0 { | |
| 228 | 231 | am.ReasoningContent = reasoning.String() | |
| 229 | 232 | } | |
| 233 | + | // Kimi rejects assistant messages with neither visible text nor | |
| 234 | + | // tool calls ("assistant must not be empty"). This can happen when | |
| 235 | + | // a previous stream produced only reasoning_content, which zot keeps | |
| 236 | + | // internally for provider replay but cannot send back as standalone | |
| 237 | + | // assistant content on OpenAI-compatible chat-completions APIs. | |
| 238 | + | if am.Content == nil && len(am.ToolCalls) == 0 { | |
| 239 | + | continue | |
| 240 | + | } | |
| 230 | 241 | out.Messages = append(out.Messages, am) | |
| 231 | 242 | case RoleTool: | |
| 232 | 243 | // Each ToolResultBlock becomes its own tool message. Preserve | |
| @@ -195,6 +195,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 195 | for _, b := range msg.Content { | 195 | for _, b := range msg.Content { | ||
| 196 | switch v := b.(type) { | 196 | switch v := b.(type) { | ||
| 197 | case TextBlock: | 197 | case TextBlock: | ||
| 198 | + | if strings.TrimSpace(v.Text) == "" { | |||
| 199 | + | continue | |||
| 200 | + | } | |||
| 198 | if text.Len() > 0 { | 201 | if text.Len() > 0 { | ||
| 199 | text.WriteString("\n") | 202 | text.WriteString("\n") | ||
| 200 | } | 203 | } | ||
| @@ -227,6 +230,14 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 227 | if reasoning.Len() > 0 && len(am.ToolCalls) > 0 { | 230 | if reasoning.Len() > 0 && len(am.ToolCalls) > 0 { | ||
| 228 | am.ReasoningContent = reasoning.String() | 231 | am.ReasoningContent = reasoning.String() | ||
| 229 | } | 232 | } | ||
| 233 | + | // Kimi rejects assistant messages with neither visible text nor | |||
| 234 | + | // tool calls ("assistant must not be empty"). This can happen when | |||
| 235 | + | // a previous stream produced only reasoning_content, which zot keeps | |||
| 236 | + | // internally for provider replay but cannot send back as standalone | |||
| 237 | + | // assistant content on OpenAI-compatible chat-completions APIs. | |||
| 238 | + | if am.Content == nil && len(am.ToolCalls) == 0 { | |||
| 239 | + | continue | |||
| 240 | + | } | |||
| 230 | out.Messages = append(out.Messages, am) | 241 | out.Messages = append(out.Messages, am) | ||
| 231 | case RoleTool: | 242 | case RoleTool: | ||
| 232 | // Each ToolResultBlock becomes its own tool message. Preserve | 243 | // Each ToolResultBlock becomes its own tool message. Preserve | ||
M internal/provider/provider_test.go +23 -0
| @@ -121,6 +121,29 @@ func TestAnthropicStreamHappyPath(t *testing.T) { | |||
| 121 | 121 | } | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | func TestOpenAIBuildRequestSkipsReasoningOnlyAssistantMessages(t *testing.T) { | |
| 125 | + | c := NewKimi("token", "").(*openaiClient) | |
| 126 | + | wire, err := c.buildRequest(Request{ | |
| 127 | + | Model: "kimi-for-coding", | |
| 128 | + | Messages: []Message{ | |
| 129 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}, | |
| 130 | + | {Role: RoleAssistant, Content: []Content{ReasoningBlock{Summary: "thinking only"}}}, | |
| 131 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |
| 132 | + | }, | |
| 133 | + | }) | |
| 134 | + | if err != nil { | |
| 135 | + | t.Fatal(err) | |
| 136 | + | } | |
| 137 | + | for i, msg := range wire.Messages { | |
| 138 | + | if msg.Role == "assistant" && msg.Content == nil && len(msg.ToolCalls) == 0 { | |
| 139 | + | t.Fatalf("message %d is empty assistant: %+v", i, msg) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | if got := len(wire.Messages); got != 2 { | |
| 143 | + | t.Fatalf("messages=%d want 2 after skipping reasoning-only assistant", got) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 124 | 147 | func TestOpenAIStreamHappyPath(t *testing.T) { | |
| 125 | 148 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 126 | 149 | w.Header().Set("content-type", "text/event-stream") | |
| @@ -121,6 +121,29 @@ func TestAnthropicStreamHappyPath(t *testing.T) { | |||||
| 121 | } | 121 | } | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | + | func TestOpenAIBuildRequestSkipsReasoningOnlyAssistantMessages(t *testing.T) { | |||
| 125 | + | c := NewKimi("token", "").(*openaiClient) | |||
| 126 | + | wire, err := c.buildRequest(Request{ | |||
| 127 | + | Model: "kimi-for-coding", | |||
| 128 | + | Messages: []Message{ | |||
| 129 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}, | |||
| 130 | + | {Role: RoleAssistant, Content: []Content{ReasoningBlock{Summary: "thinking only"}}}, | |||
| 131 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |||
| 132 | + | }, | |||
| 133 | + | }) | |||
| 134 | + | if err != nil { | |||
| 135 | + | t.Fatal(err) | |||
| 136 | + | } | |||
| 137 | + | for i, msg := range wire.Messages { | |||
| 138 | + | if msg.Role == "assistant" && msg.Content == nil && len(msg.ToolCalls) == 0 { | |||
| 139 | + | t.Fatalf("message %d is empty assistant: %+v", i, msg) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | if got := len(wire.Messages); got != 2 { | |||
| 143 | + | t.Fatalf("messages=%d want 2 after skipping reasoning-only assistant", got) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 124 | func TestOpenAIStreamHappyPath(t *testing.T) { | 147 | func TestOpenAIStreamHappyPath(t *testing.T) { | ||
| 125 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 148 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 126 | w.Header().Set("content-type", "text/event-stream") | 149 | w.Header().Set("content-type", "text/event-stream") | ||