Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `<think>` tags. Enable the
MiniMax compatibility option to get clean, separated reasoning:
Expand Down
22 changes: 15 additions & 7 deletions provider/openai/completions/completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
29 changes: 21 additions & 8 deletions provider/openai/completions/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -115,26 +116,38 @@ 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())
sp.reasoningText.WriteString(reasoningContent)
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
Expand Down
54 changes: 54 additions & 0 deletions provider/openai/completions/thinking_replay.go
Original file line number Diff line number Diff line change
@@ -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
}
163 changes: 163 additions & 0 deletions provider/openai/completions/thinking_replay_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading