From ffcced43470af95ac0d6e754681e10e52a7c19f7 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 7 Sep 2026 19:08:56 +0800 Subject: [PATCH] fix(completions): replay reasoning_content on thinking-mode tool calls 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. --- docs/providers.md | 38 +++- provider/openai/completions/completions.go | 22 ++- provider/openai/completions/stream.go | 29 ++- .../openai/completions/thinking_replay.go | 54 ++++++ .../completions/thinking_replay_test.go | 163 ++++++++++++++++ .../completions/thinking_response_test.go | 183 ++++++++++++++++++ provider/openai/completions/types.go | 26 ++- sdk/message.go | 7 +- 8 files changed, 495 insertions(+), 27 deletions(-) create mode 100644 provider/openai/completions/thinking_replay.go create mode 100644 provider/openai/completions/thinking_replay_test.go create mode 100644 provider/openai/completions/thinking_response_test.go diff --git a/docs/providers.md b/docs/providers.md index af58905..0938870 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -101,8 +101,9 @@ model := provider.ChatModel("gpt-4o-mini") | `WithBaseURL(url)` | `https://api.openai.com/v1` | Base URL for API requests | | `WithHTTPClient(client)` | `&http.Client{}` | Custom HTTP client (for proxies, timeouts, etc.) | | `WithMessageRoleCapabilities(capabilities)` | developer + mid-system enabled | Override instruction roles for a less-capable OpenAI-compatible endpoint | -| `WithDeepSeekChatCompletionsCompat()` | disabled | Map `WithReasoningEffort("none")` to DeepSeek's thinking disable toggle | +| `WithDeepSeekChatCompletionsCompat()` | disabled | Map `WithReasoningEffort("none")` to DeepSeek's thinking disable toggle; always send `reasoning_content` on replayed tool-call messages | | `WithMiniMaxChatCompletionsCompat()` | disabled | Send `reasoning_split: true` and map reasoning effort to MiniMax's thinking toggle | +| `WithKimiChatCompletionsCompat()` | disabled | Rewrite tool schemas to Moonshot-flavored JSON Schema; always send `reasoning_content` on replayed tool-call messages | ### API Endpoints for Discovery @@ -140,6 +141,41 @@ Note: `"none"` is the effort floor, which for DeepSeek means off. Sending `reasoning_effort: "none"` alone does not stop it thinking, so the provider sends `thinking: {type: "disabled"}` instead. +#### Replaying thinking-mode tool calls + +In thinking mode, DeepSeek validates replayed history when the request carries +tools: every assistant message with `tool_calls` after the last user message +must carry a `reasoning_content` key, or the API answers +`400 The reasoning_content in the thinking mode must be passed back to the API`. +An empty string passes. DeepSeek fills the gap itself while the `tool_call` id +is fresh, so the error surfaces on continuations of persisted history, such as +resuming after a tool approval, when a step produced no reasoning or the stored +message lost it. Moonshot/Kimi enforces the same rule for every assistant +tool-call message when thinking is enabled. + +The provider handles this on both sides of the wire. + +On the response side, a `reasoning_content` key that is present but empty is +recorded as a `ReasoningPart` with empty text in the `openai-chat-v1` dialect. +DeepSeek returns `"reasoning_content": ""` for a thinking-mode step that +produced no reasoning (the first streamed delta carries it too) and omits the +key entirely when thinking is disabled, so key presence is the signal. The +step's assistant message therefore keeps a record that it was a thinking-mode +step, and `GenerateResult.ReasoningParts` may contain a part whose `Text` is +empty. + +On the request side, an `openai-chat-v1` `ReasoningPart` is always sent as +`reasoning_content`, `""` included. Assistant tool-call messages that carry no +`ReasoningPart` at all, such as history persisted before this behaviour or +history whose reasoning was dropped upstream, are padded with an empty key in +two cases: + +| Situation | `reasoning_content` on assistant tool-call messages without a `ReasoningPart` | +|-----------|------------------------------------------------------| +| DeepSeek or Kimi compatibility option enabled | `""` | +| Any message in the request already carries `reasoning_content` | `""` | +| Otherwise | omitted, since plain OpenAI rejects unknown message fields | + MiniMax also uses the OpenAI-compatible endpoint, but ignores `reasoning_effort` and, by default, inlines reasoning into `content` as `` tags. Enable the MiniMax compatibility option to get clean, separated reasoning: diff --git a/provider/openai/completions/completions.go b/provider/openai/completions/completions.go index f0a5555..da92776 100644 --- a/provider/openai/completions/completions.go +++ b/provider/openai/completions/completions.go @@ -275,6 +275,7 @@ func (p *Provider) buildRequest(params *sdk.GenerateParams) (*chatRequest, error if err := p.applyChatCompletionsCompat(req); err != nil { return nil, err } + padThinkingReplay(req.Messages, p.compat) return req, nil } @@ -376,6 +377,7 @@ func convertAssistantMessage(msg sdk.Message) chatMessage { var contentParts []sdk.MessagePart var toolCalls []chatToolCall var reasoning string + var hasReasoning bool var reasoningDetails []chatReasoningDetail for _, part := range msg.Content { @@ -402,6 +404,7 @@ func convertAssistantMessage(msg sdk.Message) chatMessage { continue } reasoning += p.Text + hasReasoning = true if details := extractMiniMaxReasoningDetails(p.ProviderMetadata); len(details) > 0 { reasoningDetails = details } @@ -415,8 +418,10 @@ func convertAssistantMessage(msg sdk.Message) chatMessage { } if len(reasoningDetails) > 0 { cm.ReasoningDetails = reasoningDetails - } else if reasoning != "" { - cm.ReasoningContent = reasoning + } else if hasReasoning { + // An empty block is still a block the model emitted: keep the key so + // thinking-mode endpoints see the step's reasoning was passed back. + cm.ReasoningContent = &reasoning } if len(toolCalls) > 0 { cm.ToolCalls = toolCalls @@ -487,7 +492,10 @@ func (p *Provider) parseResponse(resp *chatResponse) (*sdk.GenerateResult, error choice := resp.Choices[0] result.Text = choice.Message.Content result.Reasoning = reasoningFromMessage(&choice.Message) - if result.Reasoning != "" || len(choice.Message.ReasoningDetails) > 0 { + // A present but empty reasoning_content is still a reasoning block: the + // model was in thinking mode and produced nothing for this step. Record + // it so the step replays with the key DeepSeek and Kimi validate. + if result.Reasoning != "" || len(choice.Message.ReasoningDetails) > 0 || choice.Message.ReasoningContent != nil { result.ReasoningParts = []sdk.ReasoningPart{{ Text: result.Reasoning, Format: sdk.ReasoningFormatOpenAIChat, @@ -633,8 +641,8 @@ func reasoningFromMessage(m *chatRespMessage) string { return text } } - if m.ReasoningContent != "" { - return m.ReasoningContent + if m.ReasoningContent != nil && *m.ReasoningContent != "" { + return *m.ReasoningContent } return m.Reasoning } @@ -645,8 +653,8 @@ func reasoningFromDelta(d *chatChunkDelta) string { return text } } - if d.ReasoningContent != "" { - return d.ReasoningContent + if d.ReasoningContent != nil && *d.ReasoningContent != "" { + return *d.ReasoningContent } return d.Reasoning } diff --git a/provider/openai/completions/stream.go b/provider/openai/completions/stream.go index bd7c250..00a8343 100644 --- a/provider/openai/completions/stream.go +++ b/provider/openai/completions/stream.go @@ -15,6 +15,7 @@ type streamProcessor struct { ch chan sdk.StreamPart textStartSent bool reasoningStartSent bool + reasoningBlockSeen bool rawFinishReason string finishReason sdk.FinishReason usage sdk.Usage @@ -115,10 +116,7 @@ func (sp *streamProcessor) processChunk(chunk *chatChunkResponse) error { func (sp *streamProcessor) processReasoning(delta *chatChunkDelta, chunkID string) { reasoningContent := reasoningFromDelta(delta) - if reasoningContent == "" { - return - } - if len(delta.ReasoningDetails) > 0 { + if reasoningContent != "" && len(delta.ReasoningDetails) > 0 { // Builder.String() is a zero-copy view, so the per-delta reads below // stay O(1) while appends stay amortized O(1). reasoningContent = trimReasoningPrefix(reasoningContent, sp.reasoningText.String()) @@ -126,15 +124,30 @@ func (sp *streamProcessor) processReasoning(delta *chatChunkDelta, chunkID strin sp.reasoningDetails = reasoningDetailsWithText(delta.ReasoningDetails, sp.reasoningText.String()) } if reasoningContent == "" { + // A delta that carries reasoning_content as "" (not null) announces a + // thinking-mode step with no reasoning text; DeepSeek sends it on the + // first delta. Open the block so the step is recorded as a reasoning + // step and replays with the key the endpoint validates. Only the first + // such delta opens a block: a closed block is never reopened by an + // empty marker. + if delta.ReasoningContent != nil && !sp.reasoningBlockSeen { + sp.startReasoning(chunkID) + } return } - if !sp.reasoningStartSent { - sp.send(&sdk.ReasoningStartPart{ID: chunkID, Format: sdk.ReasoningFormatOpenAIChat, Model: sp.chunkModel}) - sp.reasoningStartSent = true - } + sp.startReasoning(chunkID) sp.send(&sdk.ReasoningDeltaPart{ID: chunkID, Text: reasoningContent, Format: sdk.ReasoningFormatOpenAIChat, Model: sp.chunkModel}) } +func (sp *streamProcessor) startReasoning(chunkID string) { + if sp.reasoningStartSent { + return + } + sp.send(&sdk.ReasoningStartPart{ID: chunkID, Format: sdk.ReasoningFormatOpenAIChat, Model: sp.chunkModel}) + sp.reasoningStartSent = true + sp.reasoningBlockSeen = true +} + func trimReasoningPrefix(text, previous string) string { if previous == "" { return text diff --git a/provider/openai/completions/thinking_replay.go b/provider/openai/completions/thinking_replay.go new file mode 100644 index 0000000..b088dfc --- /dev/null +++ b/provider/openai/completions/thinking_replay.go @@ -0,0 +1,54 @@ +package completions + +// padThinkingReplay makes sure every assistant tool-call message in the +// request carries a reasoning_content key when the endpoint validates +// thinking-mode replays. +// +// DeepSeek (thinking mode, request carries tools) and Moonshot/Kimi (thinking +// enabled) reject a request in which an assistant message with tool_calls has +// no reasoning_content key; an empty string satisfies the check. DeepSeek fills +// the gap from its own side while the tool_call id is fresh, so the failure +// surfaces on replays of persisted history: the model emitted no reasoning for +// that step, or the stored row dropped it. Both endpoints ignore the value on +// messages that were never tool calls. +// +// The key is added when the provider runs in DeepSeek or Kimi compat, or when +// some message in the request already carries reasoning_content: the endpoint +// then demonstrably accepts the field, so an empty one is safe. Plain OpenAI +// rejects unknown message fields, so nothing is added otherwise. Messages that +// carry reasoning_details (MiniMax) already replay their reasoning and are left +// alone, as is the whole request in MiniMax compat. +func padThinkingReplay(messages []chatMessage, compat chatCompletionsCompat) { + if compat == chatCompletionsCompatMiniMax { + return + } + if !thinkingReplayValidated(messages, compat) { + return + } + for i := range messages { + m := &messages[i] + if m.Role != "assistant" || len(m.ToolCalls) == 0 { + continue + } + if m.ReasoningContent != nil || len(m.ReasoningDetails) > 0 { + continue + } + empty := "" + m.ReasoningContent = &empty + } +} + +// thinkingReplayValidated reports whether the endpoint is expected to check +// reasoning_content on replayed tool-call messages. +func thinkingReplayValidated(messages []chatMessage, compat chatCompletionsCompat) bool { + switch compat { + case chatCompletionsCompatDeepSeek, chatCompletionsCompatKimi: + return true + } + for i := range messages { + if messages[i].Role == "assistant" && messages[i].ReasoningContent != nil { + return true + } + } + return false +} diff --git a/provider/openai/completions/thinking_replay_test.go b/provider/openai/completions/thinking_replay_test.go new file mode 100644 index 0000000..a14df74 --- /dev/null +++ b/provider/openai/completions/thinking_replay_test.go @@ -0,0 +1,163 @@ +package completions_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/felinics/twilight/provider/openai/completions" + "github.com/felinics/twilight/sdk" +) + +// captureMessages serves one canned completion and records the messages the +// provider sent, so tests can assert on reasoning_content key presence. +func captureMessages(t *testing.T) (*httptest.Server, *[]map[string]json.RawMessage) { + t.Helper() + var messages []map[string]json.RawMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Messages []map[string]json.RawMessage `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + messages = body.Messages + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-replay", "model": "m", + "choices": []map[string]any{{ + "index": 0, "finish_reason": "stop", + "message": map[string]any{"role": "assistant", "content": "ok"}, + }}, + "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }) + })) + t.Cleanup(srv.Close) + return srv, &messages +} + +func toolCallMessage(id string, reasoning ...sdk.ReasoningPart) sdk.Message { + parts := make([]sdk.MessagePart, 0, len(reasoning)+1) + for _, r := range reasoning { + parts = append(parts, r) + } + parts = append(parts, sdk.ToolCallPart{ + ToolCallID: id, + ToolName: "get_weather", + Input: map[string]any{"city": "Paris"}, + }) + return sdk.Message{Role: sdk.MessageRoleAssistant, Content: parts} +} + +func weatherTool() sdk.Tool { + return sdk.Tool{ + Name: "get_weather", + Description: "Get weather", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + }, + } +} + +// assertReasoningContent checks messages[index] against want: a nil want +// means the key must be absent, otherwise it must equal *want. +func assertReasoningContent(t *testing.T, msgs []map[string]json.RawMessage, index int, want *string) { + t.Helper() + raw, ok := msgs[index]["reasoning_content"] + if !ok { + if want != nil { + t.Fatalf("messages[%d]: reasoning_content key missing, want %q", index, *want) + } + return + } + var got string + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("messages[%d]: reasoning_content is not a string: %s", index, raw) + } + if want == nil { + t.Fatalf("messages[%d]: reasoning_content should be absent, got %q", index, got) + } + if got != *want { + t.Fatalf("messages[%d]: reasoning_content = %q, want %q", index, got, *want) + } +} + +func str(s string) *string { return &s } + +// TestDoGenerate_ThinkingReplayPadding drives padThinkingReplay through one +// history under each provider configuration: a user turn, a tool call whose +// reasoning varies per case, its result, a second tool call with no +// reasoning, its result, a text answer and a follow-up user turn. Only +// messages 1 and 3 are assistant tool calls; everything else must stay bare. +func TestDoGenerate_ThinkingReplayPadding(t *testing.T) { + empty := str("") + cases := []struct { + name string + options []completions.Option + step1 []sdk.ReasoningPart // reasoning on the first tool call + wantStep1 *string // nil: key absent + wantStep2 *string + }{ + { + name: "deepseek compat pads bare tool calls", + options: []completions.Option{completions.WithDeepSeekChatCompletionsCompat()}, + wantStep1: empty, wantStep2: empty, + }, + { + name: "plain endpoint pads once the conversation carries reasoning_content", + step1: []sdk.ReasoningPart{{Text: "Paris first.", Format: sdk.ReasoningFormatOpenAIChat}}, + wantStep1: str("Paris first."), wantStep2: empty, + }, + { + name: "plain endpoint sends nothing without thinking evidence", + }, + { + name: "minimax compat never pads", + options: []completions.Option{completions.WithMiniMaxChatCompletionsCompat()}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, sent := captureMessages(t) + opts := append([]completions.Option{completions.WithAPIKey("k"), completions.WithBaseURL(srv.URL)}, tc.options...) + p := completions.New(opts...) + + history := []sdk.Message{ + sdk.UserMessage("Weather in Paris, then Tokyo."), + toolCallMessage("c1", tc.step1...), + sdk.ToolMessage(sdk.ToolResultPart{ToolCallID: "c1", ToolName: "get_weather", Result: "18C"}), + toolCallMessage("c2"), + sdk.ToolMessage(sdk.ToolResultPart{ToolCallID: "c2", ToolName: "get_weather", Result: "25C"}), + sdk.AssistantMessage("Paris 18C, Tokyo 25C."), + sdk.UserMessage("Thanks."), + } + _, err := p.DoGenerate(context.Background(), sdk.GenerateParams{ + Model: &sdk.Model{ID: "m"}, + Messages: history, + Tools: []sdk.Tool{weatherTool()}, + }) + if err != nil { + t.Fatalf("DoGenerate: %v", err) + } + + msgs := *sent + if len(msgs) != len(history) { + t.Fatalf("expected %d messages, got %d", len(history), len(msgs)) + } + for i := range msgs { + var want *string + switch i { + case 1: + want = tc.wantStep1 + case 3: + want = tc.wantStep2 + } + assertReasoningContent(t, msgs, i, want) + } + }) + } +} diff --git a/provider/openai/completions/thinking_response_test.go b/provider/openai/completions/thinking_response_test.go new file mode 100644 index 0000000..452c570 --- /dev/null +++ b/provider/openai/completions/thinking_response_test.go @@ -0,0 +1,183 @@ +package completions_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/felinics/twilight/provider/openai/completions" + "github.com/felinics/twilight/sdk" +) + +func chatResponse(finish string, message map[string]any) map[string]any { + return map[string]any{ + "id": "chatcmpl", "model": "deepseek-v4-flash", + "choices": []map[string]any{{"index": 0, "finish_reason": finish, "message": message}}, + "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } +} + +func streamEventTypes(t *testing.T, chunks []string) []sdk.StreamPartType { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + for _, c := range chunks { + fmt.Fprintf(w, "data: %s\n\n", c) + flusher.Flush() + } + fmt.Fprintf(w, "data: [DONE]\n\n") + flusher.Flush() + })) + t.Cleanup(srv.Close) + + p := completions.New(completions.WithAPIKey("k"), completions.WithBaseURL(srv.URL)) + sr, err := p.DoStream(context.Background(), sdk.GenerateParams{ + Model: &sdk.Model{ID: "deepseek-v4-flash"}, + Messages: []sdk.Message{sdk.UserMessage("weather")}, + }) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + var events []sdk.StreamPartType + for part := range sr.Stream { + events = append(events, part.Type()) + } + return events +} + +func countEvents(events []sdk.StreamPartType, want sdk.StreamPartType) int { + n := 0 + for _, ev := range events { + if ev == want { + n++ + } + } + return n +} + +// The first delta of a DeepSeek thinking-mode stream carries +// reasoning_content as "" (null afterwards). That first "" opens a reasoning +// block; a later "" never reopens a closed one; null alone opens nothing. +func TestDoStream_ReasoningContentMarkers(t *testing.T) { + cases := []struct { + name string + chunks []string + wantStarts int + wantDeltas int + }{ + { + name: "empty first delta opens one block", + chunks: []string{ + `{"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""},"finish_reason":null}]}`, + `{"id":"c1","choices":[{"index":0,"delta":{"content":"Now Tokyo.","reasoning_content":null},"finish_reason":null}]}`, + `{"id":"c1","choices":[{"index":0,"delta":{"content":null,"reasoning_content":null},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":10,"total_tokens":15}}`, + }, + wantStarts: 1, + }, + { + name: "null key opens nothing", + chunks: []string{ + `{"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null},"finish_reason":null}]}`, + `{"id":"c1","choices":[{"index":0,"delta":{"content":"Now Tokyo."},"finish_reason":null}]}`, + `{"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":10,"total_tokens":15}}`, + }, + }, + { + name: "empty marker after text does not reopen a closed block", + chunks: []string{ + `{"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Let me think."},"finish_reason":null}]}`, + `{"id":"c1","choices":[{"index":0,"delta":{"content":"Answer","reasoning_content":null},"finish_reason":null}]}`, + `{"id":"c1","choices":[{"index":0,"delta":{"content":" here.","reasoning_content":""},"finish_reason":null}]}`, + `{"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":10,"total_tokens":15}}`, + }, + wantStarts: 1, + wantDeltas: 1, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + events := streamEventTypes(t, tc.chunks) + if n := countEvents(events, sdk.StreamPartTypeReasoningStart); n != tc.wantStarts { + t.Fatalf("reasoning-start: got %d, want %d; events=%v", n, tc.wantStarts, events) + } + if n := countEvents(events, sdk.StreamPartTypeReasoningEnd); n != tc.wantStarts { + t.Fatalf("reasoning-end: got %d, want %d; events=%v", n, tc.wantStarts, events) + } + if n := countEvents(events, sdk.StreamPartTypeReasoningDelta); n != tc.wantDeltas { + t.Fatalf("reasoning-delta: got %d, want %d; events=%v", n, tc.wantDeltas, events) + } + }) + } +} + +// A two-step run with no compat option. Step 1 answers with a tool call and +// "reasoning_content": "" (thinking mode, no reasoning produced); step 2 +// answers with text and no key at all. The empty key must be recorded as a +// ReasoningPart and replayed on the second request, the absent key must not. +func TestGenerateTextResult_EmptyReasoningStepReplaysKey(t *testing.T) { + var call int + var secondRequest []map[string]json.RawMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call++ + w.Header().Set("Content-Type", "application/json") + switch call { + case 1: + json.NewEncoder(w).Encode(chatResponse("tool_calls", map[string]any{ + "role": "assistant", "content": "", "reasoning_content": "", + "tool_calls": []map[string]any{{ + "id": "call_1", "type": "function", + "function": map[string]any{"name": "get_weather", "arguments": `{"city":"Paris"}`}, + }}, + })) + default: + var body struct { + Messages []map[string]json.RawMessage `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + secondRequest = body.Messages + json.NewEncoder(w).Encode(chatResponse("stop", map[string]any{ + "role": "assistant", "content": "Paris is 18C.", + })) + } + })) + defer srv.Close() + + tool := weatherTool() + tool.Execute = func(ctx *sdk.ToolExecContext, input any) (any, error) { return "18C", nil } + + p := completions.New(completions.WithAPIKey("k"), completions.WithBaseURL(srv.URL)) + result, err := sdk.GenerateTextResult( + context.Background(), + sdk.WithModel(p.ChatModel("deepseek-v4-flash")), + sdk.WithMessages([]sdk.Message{sdk.UserMessage("Weather in Paris?")}), + sdk.WithTools([]sdk.Tool{tool}), + sdk.WithMaxSteps(3), + ) + if err != nil { + t.Fatalf("GenerateTextResult: %v", err) + } + if result.Text != "Paris is 18C." || call != 2 || len(result.Steps) != 2 { + t.Fatalf("text %q after %d calls, %d steps", result.Text, call, len(result.Steps)) + } + + if parts := result.Steps[0].ReasoningParts; len(parts) != 1 || parts[0].Text != "" || parts[0].Format != sdk.ReasoningFormatOpenAIChat { + t.Fatalf("step 1 reasoning parts: got %#v, want one empty openai-chat-v1 part", parts) + } + if parts := result.Steps[1].ReasoningParts; len(parts) != 0 { + t.Fatalf("step 2 reasoning parts: got %#v, want none when the key is absent", parts) + } + + if len(secondRequest) != 3 { + t.Fatalf("second request: expected user, assistant, tool messages, got %d", len(secondRequest)) + } + assertReasoningContent(t, secondRequest, 0, nil) + assertReasoningContent(t, secondRequest, 1, str("")) + assertReasoningContent(t, secondRequest, 2, nil) +} diff --git a/provider/openai/completions/types.go b/provider/openai/completions/types.go index 3bfd995..4dd3565 100644 --- a/provider/openai/completions/types.go +++ b/provider/openai/completions/types.go @@ -48,9 +48,12 @@ type chatFunction struct { } type chatMessage struct { - Role string `json:"role"` - Content any `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` + Role string `json:"role"` + Content any `json:"content"` + // ReasoningContent is a pointer so an empty reasoning block can be sent as + // "" instead of being dropped: DeepSeek and Kimi thinking modes validate + // the key's presence on replayed tool-call messages, not its length. + ReasoningContent *string `json:"reasoning_content,omitempty"` ReasoningDetails []chatReasoningDetail `json:"reasoning_details,omitempty"` ToolCalls []chatToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` @@ -101,9 +104,12 @@ type chatChoice struct { } type chatRespMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + // ReasoningContent is a pointer because the key's presence is itself a + // signal: DeepSeek returns "" for a thinking-mode step that produced no + // reasoning and omits the key when thinking is disabled. + ReasoningContent *string `json:"reasoning_content,omitempty"` ReasoningDetails []chatReasoningDetail `json:"reasoning_details,omitempty"` Reasoning string `json:"reasoning,omitempty"` Refusal string `json:"refusal,omitempty"` @@ -157,9 +163,11 @@ type chatChunkChoice struct { } type chatChunkDelta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + // ReasoningContent is a pointer for the same reason as in chatRespMessage: + // DeepSeek's first thinking-mode delta carries "" and later deltas null. + ReasoningContent *string `json:"reasoning_content,omitempty"` ReasoningDetails []chatReasoningDetail `json:"reasoning_details,omitempty"` Reasoning string `json:"reasoning,omitempty"` Refusal string `json:"refusal,omitempty"` diff --git a/sdk/message.go b/sdk/message.go index 01b1a93..eaf0646 100644 --- a/sdk/message.go +++ b/sdk/message.go @@ -103,8 +103,11 @@ const ( // upstream model's own token in a single reasoning_opaque per response. ReasoningFormatCopilot ReasoningFormat = "copilot-v1" // ReasoningFormatOpenAIChat is the Chat Completions reasoning dialect used - // by DeepSeek and MiniMax. It carries no opaque token: replaying it affects - // answer quality, never request validity. + // by DeepSeek, Kimi and MiniMax. It carries no opaque token, but DeepSeek + // and Kimi thinking modes still validate the replay: an assistant tool-call + // message without a reasoning_content key is rejected with 400, while an + // empty value passes. A part with empty Text therefore matters and is + // replayed as reasoning_content: "". ReasoningFormatOpenAIChat ReasoningFormat = "openai-chat-v1" )