detached 0f332fa

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) {
195195 for _, b := range msg.Content {
196196 switch v := b.(type) {
197197 case TextBlock:
198+ if strings.TrimSpace(v.Text) == "" {
199+ continue
200+ }
198201 if text.Len() > 0 {
199202 text.WriteString("\n")
200203 }
@@ -227,6 +230,14 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
227230 if reasoning.Len() > 0 && len(am.ToolCalls) > 0 {
228231 am.ReasoningContent = reasoning.String()
229232 }
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+ }
230241 out.Messages = append(out.Messages, am)
231242 case RoleTool:
232243 // 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. Preserve243 // 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) {
121121 }
122122 }
123123
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+
124147 func TestOpenAIStreamHappyPath(t *testing.T) {
125148 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126149 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")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close