mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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:
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user