Stream OpenRouter reasoning tokens in the OpenAI client

OpenRouter and other OpenAI-compatible gateways normalize chain-of-thought
into a "reasoning" delta field, while DeepSeek's direct API uses
"reasoning_content". The OpenAI-compatible client only read
"reasoning_content", so reasoning models routed via OpenRouter (e.g.
deepseek/deepseek-v4-pro, the configured Assistant default) had every
thinking token dropped. The Assistant showed a long dead pause and then the
answer instead of a live thinking stream, which read as "DeepSeek doesn't
stream".

Parse "reasoning" on both the streaming delta and the non-streaming message,
and surface it as a thinking event alongside the existing reasoning_content
path. Direct DeepSeek (reasoning_content) is unchanged. Adds a regression
test feeding an OpenRouter-style reasoning stream.
This commit is contained in:
rcourtman
2026-06-04 23:25:50 +01:00
parent e7c091b63f
commit 80b20cb1ff
2 changed files with 86 additions and 14 deletions
+35 -14
View File
@@ -142,7 +142,8 @@ type openaiChoice struct {
type openaiRespMsg struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek thinking mode
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek direct thinking mode
Reasoning string `json:"reasoning,omitempty"` // OpenRouter / OpenAI-compatible gateways
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
}
@@ -438,17 +439,23 @@ func (c *OpenAIClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
choice := openaiResp.Choices[0]
// For DeepSeek reasoner, the actual content may be in reasoning_content
// when content is empty (it shows the "thinking" but that's the full response)
// Reasoning models expose chain-of-thought separately: DeepSeek's direct API
// in "reasoning_content", OpenRouter and other gateways in "reasoning".
reasoning := choice.Message.ReasoningContent
if reasoning == "" {
reasoning = choice.Message.Reasoning
}
// When a reasoner returns no visible content, fall back to the reasoning text
// so the turn still carries the model's output instead of an empty string.
contentToUse := choice.Message.Content
if contentToUse == "" && choice.Message.ReasoningContent != "" {
// DeepSeek reasoner puts output in reasoning_content
contentToUse = choice.Message.ReasoningContent
if contentToUse == "" && reasoning != "" {
contentToUse = reasoning
}
result := &ChatResponse{
Content: contentToUse,
ReasoningContent: choice.Message.ReasoningContent, // DeepSeek thinking mode
ReasoningContent: reasoning, // surfaced as the turn's thinking
Model: openaiResp.Model,
StopReason: choice.FinishReason,
InputTokens: openaiResp.Usage.PromptTokens,
@@ -557,10 +564,17 @@ type openaiStreamChoice struct {
}
type openaiStreamDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []openaiToolCallDelta `json:"tool_calls,omitempty"`
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
// ReasoningContent carries reasoning tokens on DeepSeek's direct API.
ReasoningContent string `json:"reasoning_content,omitempty"`
// Reasoning carries reasoning tokens on OpenRouter and other OpenAI-compatible
// gateways, which normalize chain-of-thought into "reasoning" rather than
// DeepSeek's "reasoning_content". Without this, reasoning models routed via
// OpenRouter stream their thinking into a field Pulse never read, so the user
// saw a long dead pause instead of a live thinking stream.
Reasoning string `json:"reasoning,omitempty"`
ToolCalls []openaiToolCallDelta `json:"tool_calls,omitempty"`
}
type openaiToolCallDelta struct {
@@ -830,11 +844,18 @@ func (c *OpenAIClient) ChatStream(ctx context.Context, req ChatRequest, callback
})
}
// Reasoning content (DeepSeek)
if delta.ReasoningContent != "" {
// Reasoning tokens. DeepSeek's direct API uses "reasoning_content";
// OpenRouter and other OpenAI-compatible gateways use "reasoning".
// A provider emits one or the other, so surface whichever is present.
if reasoning := delta.ReasoningContent; reasoning != "" {
callback(StreamEvent{
Type: "thinking",
Data: ThinkingEvent{Text: delta.ReasoningContent},
Data: ThinkingEvent{Text: reasoning},
})
} else if reasoning := delta.Reasoning; reasoning != "" {
callback(StreamEvent{
Type: "thinking",
Data: ThinkingEvent{Text: reasoning},
})
}
+51
View File
@@ -94,6 +94,57 @@ func TestOpenAIClient_ChatStream_Success(t *testing.T) {
assert.True(t, doneCalled)
}
// TestOpenAIClient_ChatStream_OpenRouterReasoning guards the OpenRouter reasoning
// path. OpenRouter (and other OpenAI-compatible gateways) stream chain-of-thought
// in the "reasoning" delta field rather than DeepSeek's "reasoning_content". A
// reasoning model routed via OpenRouter must surface those tokens as live thinking
// events; previously they were dropped, leaving the user with a long dead pause.
func TestOpenAIClient_ChatStream_OpenRouterReasoning(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
events := []string{
`{"id":"gen-1","choices":[{"delta":{"reasoning":"Let me "}}]}`,
`{"id":"gen-1","choices":[{"delta":{"reasoning":"think."}}]}`,
`{"id":"gen-1","choices":[{"delta":{"content":"Answer"}}]}`,
`{"id":"gen-1","choices":[{"delta":{},"finish_reason":"stop"}]}`,
`[DONE]`,
}
for _, event := range events {
fmt.Fprintf(w, "data: %s\n\n", event)
w.(http.Flusher).Flush()
time.Sleep(5 * time.Millisecond)
}
}))
defer server.Close()
client := NewOpenAIClient("sk-test", "deepseek/deepseek-v4-pro", server.URL, 0)
var thinking, content string
var doneCalled bool
callback := func(event StreamEvent) {
switch event.Type {
case "thinking":
if data, ok := event.Data.(ThinkingEvent); ok {
thinking += data.Text
}
case "content":
if data, ok := event.Data.(ContentEvent); ok {
content += data.Text
}
case "done":
doneCalled = true
}
}
err := client.ChatStream(context.Background(), ChatRequest{Messages: []Message{{Role: "user", Content: "Hi"}}}, callback)
require.NoError(t, err)
assert.Equal(t, "Let me think.", thinking, "OpenRouter 'reasoning' deltas should surface as thinking events")
assert.Equal(t, "Answer", content)
assert.True(t, doneCalled)
}
func TestOpenAIClient_ChatStream_ToolCall(t *testing.T) {
// Mock tool call stream
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {