8000 fix(completions): replay reasoning_content on thinking-mode tool call… · felinics/twilight@18a9879 · GitHub
[go: up one dir, main page]

Skip to content

Commit 18a9879

Browse files
authored
fix(completions): replay reasoning_content on thinking-mode tool calls (#48)
DeepSeek thinking mode rejects a request that carries tools when an assistant tool-call message after the last user turn has no reasoning_content key (400 "must be passed back to the API"). An empty string passes. Kimi enforces the same rule. - Response: reasoning_content is decoded as *string. A present-but-empty key yields a ReasoningPart with empty text; the stream opens one block on the first "" delta and never reopens a closed block. - Request: any openai-chat-v1 ReasoningPart is sent, "" included. padThinkingReplay adds an empty key to tool-call messages without a ReasoningPart (older persisted history) under DeepSeek/Kimi compat or when the request already carries reasoning_content. Plain OpenAI and MiniMax are left untouched. Verified against deepseek-v4-flash / v4-pro in stream and non-stream mode.
1 parent 3e7b614 commit 18a9879

8 files changed

Lines changed: 495 additions & 27 deletions

File tree

‎docs/providers.md‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ model := provider.ChatModel("gpt-4o-mini")
101101
| `WithBaseURL(url)` | `https://api.openai.com/v1` | Base URL for API requests |
102102
| `WithHTTPClient(client)` | `&http.Client{}` | Custom HTTP client (for proxies, timeouts, etc.) |
103103
| `WithMessageRoleCapabilities(capabilities)` | developer + mid-system enabled | Override instruction roles for a less-capable OpenAI-compatible endpoint |
104-
| `WithDeepSeekChatCompletionsCompat()` | disabled | Map `WithReasoningEffort("none")` to DeepSeek's thinking disable toggle |
104+
| `WithDeepSeekChatCompletionsCompat()` | disabled | Map `WithReasoningEffort("none")` to DeepSeek's thinking disable toggle; always send `reasoning_content` on replayed tool-call messages |
105105
| `WithMiniMaxChatCompletionsCompat()` | disabled | Send `reasoning_split: true` and map reasoning effort to MiniMax's thinking toggle |
106+
| `WithKimiChatCompletionsCompat()` | disabled | Rewrite tool schemas to Moonshot-flavored JSON Schema; always send `reasoning_content` on replayed tool-call messages |
106107

107108
### API Endpoints for Discovery
108109

@@ -140,6 +141,41 @@ Note: `"none"` is the effort floor, which for DeepSeek means off. Sending
140141
`reasoning_effort: "none"` alone does not stop it thinking, so the provider sends
141142
`thinking: {type: "disabled"}` instead.
142143

144+
#### Replaying thinking-mode tool calls
145+
146+
In thinking mode, DeepSeek validates replayed history when the request carries
147+
tools: every assistant message with `tool_calls` after the last user message
148+
must carry a `reasoning_content` key, or the API answers
149+
`400 The reasoning_content in the thinking mode must be passed back to the API`.
150+
An empty string passes. DeepSeek fills the gap itself while the `tool_call` id
151+
is fresh, so the error surfaces on continuations of persisted history, such as
152+
resuming after a tool approval, when a step produced no reasoning or the stored
153+
message lost it. Moonshot/Kimi enforces the same rule for every assistant
154+
tool-call message when thinking is enabled.
155+
156+
The provider handles this on both sides of the wire.
157+
158+
On the response side, a `reasoning_content` key that is present but empty is
159+
recorded as a `ReasoningPart` with empty text in the `openai-chat-v1` dialect.
160+
DeepSeek returns `"reasoning_content": ""` for a thinking-mode step that
161+
produced no reasoning (the first streamed delta carries it too) and omits the
162+
key entirely when thinking is disabled, so key presence is the signal. The
163+
step's assistant message therefore keeps a record that it was a thinking-mode
164+
step, and `GenerateResult.ReasoningParts` may contain a part whose `Text` is
165+
empty.
166+
167+
On the request side, an `openai-chat-v1` `ReasoningPart` is always sent as
168+
`reasoning_content`, `""` included. Assistant tool-call messages that carry no
169+
`ReasoningPart` at all, such as history persisted before this behaviour or
170+
history whose reasoning was dropped upstream, are padded with an empty key in
171+
two cases:
172+
173+
| Situation | `reasoning_content` on assistant tool-call messages without a `ReasoningPart` |
174+
|-----------|------------------------------------------------------|
175+
| DeepSeek or Kimi compatibility option enabled | `""` |
176+
| Any message in the request already carries `reasoning_content` | `""` |
177+
| Otherwise | omitted, since plain OpenAI rejects unknown message fields |
178+
143179
MiniMax also uses the OpenAI-compatible endpoint, but ignores `reasoning_effort`
144180
and, by default, inlines reasoning into `content` as `<think>` tags. Enable the
145181
MiniMax compatibility option to get clean, separated reasoning:

‎provider/openai/completions/completions.go‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ func (p *Provider) buildRequest(params *sdk.GenerateParams) (*chatRequest, error
275275
if err := p.applyChatCompletionsCompat(req); err != nil {
276276
return nil, err
277277
}
278+
padThinkingReplay(req.Messages, p.compat)
278279
return req, nil
279280
}
280281

@@ -376,6 +377,7 @@ func convertAssistantMessage(msg sdk.Message) chatMessage {
376377
var contentParts []sdk.MessagePart
377378
var toolCalls []chatToolCall
378379
var reasoning string
380+
var hasReasoning bool
379381
var reasoningDetails []chatReasoningDetail
380382

381383
for _, part := range msg.Content {
@@ -402,6 +404,7 @@ func convertAssistantMessage(msg sdk.Message) chatMessage {
402404
continue
403405
}
404406
reasoning += p.Text
407+
hasReasoning = true
405408
if details := extractMiniMaxReasoningDetails(p.ProviderMetadata); len(details) > 0 {
406409
reasoningDetails = details
407410
}
@@ -415,8 +418,10 @@ func convertAssistantMessage(msg sdk.Message) chatMessage {
415418
}
416419
if len(reasoningDetails) > 0 {
417420
cm.ReasoningDetails = reasoningDetails
418-
} else if reasoning != "" {
419-
cm.ReasoningContent = reasoning
421+
} else if hasReasoning {
422+
// An empty block is still a block the model emitted: keep the key so
423+
// thinking-mode endpoints see the step's reasoning was passed back.
424+
cm.ReasoningContent = &reasoning
420425
}
421426
if len(toolCalls) > 0 {
422427
cm.ToolCalls = toolCalls
@@ -487,7 +492,10 @@ func (p *Provider) parseResponse(resp *chatResponse) (*sdk.GenerateResult, error
487492
choice := resp.Choices[0]
488493
result.Text = choice.Message.Content
489494
result.Reasoning = reasoningFromMessage(&choice.Message)
490-
if result.Reasoning != "" || len(choice.Message.ReasoningDetails) > 0 {
495+
// A present but empty reasoning_content is still a reasoning block: the
496+
// model was in thinking mode and produced nothing for this step. Record
497+
// it so the step replays with the key DeepSeek and Kimi validate.
498+
if result.Reasoning != "" || len(choice.Message.ReasoningDetails) > 0 || choice.Message.ReasoningContent != nil {
491499
result.ReasoningParts = []sdk.ReasoningPart{{
492500
Text: result.Reasoning,
493501
Format: sdk.ReasoningFormatOpenAIChat,
@@ -633,8 +641,8 @@ func reasoningFromMessage(m *chatRespMessage) string {
633641
return text
634642
}
635643
}
636-
if m.ReasoningContent != "" {
637-
return m.ReasoningContent
644+
if m.ReasoningContent != nil && *m.ReasoningContent != "" {
645+
return *m.ReasoningContent
638646
}
639647
return m.Reasoning
640648
}
@@ -645,8 +653,8 @@ func reasoningFromDelta(d *chatChunkDelta) string {
645653
return text
646654
}
647655
}
648-
if d.ReasoningContent != "" {
649-
return d.ReasoningContent
656+
if d.ReasoningContent != nil && *d.ReasoningContent != "" {
657+
return *d.ReasoningContent
650658
}
651659
return d.Reasoning
652660
}

‎provider/openai/completions/stream.go‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type streamProcessor struct {
1515
ch chan sdk.StreamPart
1616
textStartSent bool
1717
reasoningStartSent bool
18+
reasoningBlockSeen bool
1819
rawFinishReason string
1920
finishReason sdk.FinishReason
2021
usage sdk.Usage
@@ -115,26 +116,38 @@ func (sp *streamProcessor) processChunk(chunk *chatChunkResponse) error {
115116

116117
func (sp *streamProcessor) processReasoning(delta *chatChunkDelta, chunkID string) {
117118
reasoningContent := reasoningFromDelta(delta)
118-
if reasoningContent == "" {
119-
return
120-
}
121-
if len(delta.ReasoningDetails) > 0 {
119+
if reasoningContent != "" && len(delta.ReasoningDetails) > 0 {
122120
// Builder.String() is a zero-copy view, so the per-delta reads below
123121
// stay O(1) while appends stay amortized O(1).
124122
reasoningContent = trimReasoningPrefix(reasoningContent, sp.reasoningText.String())
125123
sp.reasoningText.WriteString(reasoningContent)
126124
sp.reasoningDetails = reasoningDetailsWithText(delta.ReasoningDetails, sp.reasoningText.String())
127125
}
128126
if reasoningContent == "" {
127+
// A delta that carries reasoning_content as "" (not null) announces a
128+
// thinking-mode step with no reasoning text; DeepSeek sends it on the
129+
// first delta. Open the block so the step is recorded as a reasoning
130+
// step and replays with the key the endpoint validates. Only the first
131+
// such delta opens a block: a closed block is never reopened by an
132+
// empty marker.
133+
if delta.ReasoningContent != nil && !sp.reasoningBlockSeen {
134+
sp.startReasoning(chunkID)
135+
}
129136
return
130137
}
131-
if !sp.reasoningStartSent {
132-
sp.send(&sdk.ReasoningStartPart{ID: chunkID, Format: sdk.ReasoningFormatOpenAIChat, Model: sp.chunkModel})
133-
sp.reasoningStartSent = true
134-
}
138+
sp.startReasoning(chunkID)
135139
sp.send(&sdk.ReasoningDeltaPart{ID: chunkID, Text: reasoningContent, Format: sdk.ReasoningFormatOpenAIChat, Model: sp.chunkModel})
136140
}
137141

142+
func (sp *streamProcessor) startReasoning(chunkID string) {
143+
if sp.reasoningStartSent {
144+
return
145+
}
146+
sp.send(&sdk.ReasoningStartPart{ID: chunkID, Format: sdk.ReasoningFormatOpenAIChat, Model: sp.chunkModel})
147+
sp.reasoningStartSent = true
148+
sp.reasoningBlockSeen = true
149+
}
150+
138151
func trimReasoningPrefix(text, previous string) string {
139152
if previous == "" {
140153
return text
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package completions
2+
3+
// padThinkingReplay makes sure every assistant tool-call message in the
4+
// request carries a reasoning_content key when the endpoint validates
5+
// thinking-mode replays.
6+
//
7+
// DeepSeek (thinking mode, request carries tools) and Moonshot/Kimi (thinking
8+
// enabled) reject a request in which an assistant message with tool_calls has
9+
// no reasoning_content key; an empty string satisfies the check. DeepSeek fills
10+
// the gap from its own side while the tool_call id is fresh, so the failure
11+
// surfaces on replays of persisted history: the model emitted no reasoning for
12+
// that step, or the stored row dropped it. Both endpoints ignore the value on
13+
// messages that were never tool calls.
14+
//
15+
// The key is added when the provider runs in DeepSeek or Kimi compat, or when
16+
// some message in the request already carries reasoning_content: the endpoint
17+
// then demonstrably accepts the field, so an empty one is safe. Plain OpenAI
18+
// rejects unknown message fields, so nothing is added otherwise. Messages that
19+
// carry reasoning_details (MiniMax) already replay their reasoning and are left
20+
// alone, as is the whole request in MiniMax compat.
21+
func padThinkingReplay(messages []chatMessage, compat chatCompletionsCompat) {
22+
if compat == chatCompletionsCompatMiniMax {
23+
return
24+
}
25+
if !thinkingReplayValidated(messages, compat) {
26+
return
27+
}
28+
for i := range messages {
29+
m := &messages[i]
30+
if m.Role != "assistant" || len(m.ToolCalls) == 0 {
31+
continue
32+
}
33+
if m.ReasoningContent != nil || len(m.ReasoningDetails) > 0 {
34+
continue
35+
}
36+
empty := ""
37+
m.ReasoningContent = &empty
38+
}
39+
}
40+
41+
// thinkingReplayValidated reports whether the endpoint is expected to check
42+
// reasoning_content on replayed tool-call messages.
43+
func thinkingReplayValidated(messages []chatMessage, compat chatCompletionsCompat) bool {
44+
switch compat {
45+
case chatCompletionsCompatDeepSeek, chatCompletionsCompatKimi:
46+
return true
47+
}
48+
for i := range messages {
49+
if messages[i].Role == "assistant" && messages[i].ReasoningContent != nil {
50+
return true
51+
}
52+
}
53+
return false
54+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package completions_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/felinics/twilight/provider/openai/completions"
11+
"github.com/felinics/twilight/sdk"
12+
)
13+
14+
// captureMessages serves one canned completion and records the messages the
15+
// provider sent, so tests can assert on reasoning_content key presence.
16+
func captureMessages(t *testing.T) (*httptest.Server, *[]map[string]json.RawMessage) {
17+
t.Helper()
18+
var messages []map[string]json.RawMessage
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
var body struct {
21+
Messages []map[string]json.RawMessage `json:"messages"`
22+
}
23+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
24+
t.Fatalf("decode request body: %v", err)
25+
}
26+
messages = body.Messages
27+
w.Header().Set("Content-Type", "application/json")
28+
json.NewEncoder(w).Encode(map[string]any{
29+
"id": "chatcmpl-replay", "model": "m",
30+
"choices": []map[string]any{{
31+
"index": 0, "finish_reason": "stop",
32+
"message": map[string]any{"role": "assistant", "content": "ok"},
33+
}},
34+
"usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
35+
})
36+
}))
37+
t.Cleanup(srv.Close)
38+
return srv, &messages
39+
}
40+
41+
func toolCallMessage(id string, reasoning ...sdk.ReasoningPart) sdk.Message {
42+
parts := make([]sdk.MessagePart, 0, len(reasoning)+1)
43+
for _, r := range reasoning {
44+
parts = append(parts, r)
45+
}
46+
parts = append(parts, sdk.ToolCallPart{
47+
ToolCallID: id,
48+
ToolName: "get_weather",
49+
Input: map[string]any{"city": "Paris"},
50+
})
51+
return sdk.Message{Role: sdk.MessageRoleAssistant, Content: parts}
52+
}
53+
54+
func weatherTool() sdk.Tool {
55+
return sdk.Tool{
56+
Name: "get_weather",
57+
Description: "Get weather",
58+
Parameters: map[string]any{
59+
"type": "object",
60+
"properties": map[string]any{"city": map[string]any{"type": "string"}},
61+
},
62+
}
63+
}
64+
65+
// assertReasoningContent checks messages[index] against want: a nil want
66+
// means the key must be absent, otherwise it must equal *want.
67+
func assertReasoningContent(t *testing.T, msgs []map[string]json.RawMessage, index int, want *string) {
68+
t.Helper()
69+
raw, ok := msgs[index]["reasoning_content"]
70+
if !ok {
71+
if want != nil {
72+
t.Fatalf("messages[%d]: reasoning_content key missing, want %q", index, *want)
73+
}
74+
return
75+
}
76+
var got string
77+
if err := json.Unmarshal(raw, &got); err != nil {
78+
t.Fatalf("messages[%d]: reasoning_content is not a string: %s", index, raw)
79+
}
80+
if want == nil {
81+
t.Fatalf("messages[%d]: reasoning_content should be absent, got %q", index, got)
82+
}
83+
if got != *want {
84+
t.Fatalf("messages[%d]: reasoning_content = %q, want %q", index, got, *want)
85+
}
86+
}
87+
88+
func str(s string) *string { return &s }
89+
90+
// TestDoGenerate_ThinkingReplayPadding drives padThinkingReplay through one
91+
// history under each provider configuration: a user turn, a tool call whose
92+
// reasoning varies per case, its result, a second tool call with no
93+
// reasoning, its result, a text answer and a follow-up user turn. Only
94+
// messages 1 and 3 are assistant tool calls; everything else must stay bare.
95+
func TestDoGenerate_ThinkingReplayPadding(t *testing.T) {
96+
empty := str("")
97+
cases := []struct {
98+
name string
99+
options []completions.Option
100+
step1 []sdk.ReasoningPart // reasoning on the first tool call
101+
wantStep1 *string // nil: key absent
102+
wantStep2 *string
103+
}{
104+
{
105+
name: "deepseek compat pads bare tool calls",
106+
options: []completions.Option{completions.WithDeepSeekChatCompletionsCompat()},
107+
wantStep1: empty, wantStep2: empty,
108+
},
109+
{
110+
name: "plain endpoint pads once the conversation carries reasoning_content",
111+
step1: []sdk.ReasoningPart{{Text: "Paris first.", Format: sdk.ReasoningFormatOpenAIChat}},
112+
wantStep1: str("Paris first."), wantStep2: empty,
113+
},
114+
{
115+
name: "plain endpoint sends nothing without thinking evidence",
116+
},
117+
{
118+
name: "minimax compat never pads",
119+
options: []completions.Option{completions.WithMiniMaxChatCompletionsCompat()},
120+
},
121+
}
122+
123+
for _, tc := range cases {
124+
t.Run(tc.name, func(t *testing.T) {
125+
srv, sent := captureMessages(t)
126+
opts := append([]completions.Option{completions.WithAPIKey("k"), completions.WithBaseURL(srv.URL)}, tc.options...)
127+
p := completions.New(opts...)
128+
129+
history := []sdk.Message{
130+
sdk.UserMessage("Weather in Paris, then Tokyo."),
131+
toolCallMessage("c1", tc.step1...),
132+
sdk.ToolMessage(sdk.ToolResultPart{ToolCallID: "c1", ToolName: "get_weather", Result: "18C"}),
133+
toolCallMessage("c2"),
134+
sdk.ToolMessage(sdk.ToolResultPart{ToolCallID: "c2", ToolName: "get_weather", Result: "25C"}),
135+
sdk.AssistantMessage("Paris 18C, Tokyo 25C."),
136+
sdk.UserMessage("Thanks."),
137+
}
138+
_, err := p.DoGenerate(context.Background(), sdk.GenerateParams{
139+
Model: &sdk.Model{ID: "m"},
140+
Messages: history,
141+
Tools: []sdk.Tool{weatherTool()},
142+
})
143+
if err != nil {
144+
t.Fatalf("DoGenerate: %v", err)
145+
}
146+
147+
msgs := *sent
148+
if len(msgs) != len(history) {
149+
t.Fatalf("expected %d messages, got %d", len(history), len(msgs))
150+
}
151+
for i := range msgs {
152+
var want *string
153+
switch i {
154+
case 1:
155+
want = tc.wantStep1
156+
case 3:
157+
want = tc.wantStep2
158+
}
159+
assertReasoningContent(t, msgs, i, want)
160+
}
161+
})
162+
}
163+
}

0 commit com 32FA ments

Comments
 (0)
0