From 80b20cb1ff81037da5b4bed80ca9d9109ecfd67c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 4 Jun 2026 23:25:50 +0100 Subject: [PATCH] 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. --- internal/ai/providers/openai.go | 49 ++++++++++++++++++-------- internal/ai/providers/openai_test.go | 51 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/internal/ai/providers/openai.go b/internal/ai/providers/openai.go index 3d91a489d..3722ef847 100644 --- a/internal/ai/providers/openai.go +++ b/internal/ai/providers/openai.go @@ -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}, }) } diff --git a/internal/ai/providers/openai_test.go b/internal/ai/providers/openai_test.go index 04029d845..6c6d48cb8 100644 --- a/internal/ai/providers/openai_test.go +++ b/internal/ai/providers/openai_test.go @@ -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) {