fix: record reasoning from OpenRouter, Anthropic, and dashboard chat

extractDelta and extractCompletion read only reasoning_content, so
reasoning from OpenRouter (reasoning, reasoning_details) and Anthropic
(thinking_delta blocks) was dropped from logged completions. The
streaming path now accumulates reasoning and content separately and
wraps reasoning in think tags, matching the non-streaming format.

The dashboard Test Chat page and the Anthropic stream/translate paths
also recognize the OpenRouter and Anthropic reasoning fields.
This commit is contained in:
2026-08-29 19:07:12 +02:00
parent 72101f92fb
commit 5c3635b2ea
7 changed files with 249 additions and 31 deletions

View File

@@ -98,8 +98,12 @@ export default function Chat() {
try {
const chunk = JSON.parse(data)
if (chunk.usage) usage = chunk.usage
const reasoningDelta: string = chunk.choices?.[0]?.delta?.reasoning_content ?? ''
const contentDelta: string = chunk.choices?.[0]?.delta?.content ?? ''
const delta = chunk.choices?.[0]?.delta ?? {}
const reasoningDetails: string = (delta.reasoning_details ?? [])
.map((d: { text?: string; summary?: string }) => d.text ?? d.summary ?? '')
.join('')
const reasoningDelta: string = (delta.reasoning_content ?? '') + (delta.reasoning ?? '') + reasoningDetails
const contentDelta: string = delta.content ?? ''
if (reasoningDelta || contentDelta) {
if (firstTokenMs === null) firstTokenMs = performance.now()
reasoning += reasoningDelta

View File

@@ -122,7 +122,7 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch
w.WriteHeader(http.StatusOK)
var ttft *int
var sb strings.Builder
var reasoning, content strings.Builder
created := time.Now().Unix()
for {
ev, err := stream.Recv()
@@ -136,8 +136,11 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch
ms := int(time.Since(start).Milliseconds())
ttft = &ms
}
// Accumulate assistant content from delta chunks.
sb.WriteString(extractDelta(ev.Data))
// Accumulate reasoning and content deltas separately so the recorded
// completion wraps reasoning in think tags, matching extractCompletion.
r, c := extractDelta(ev.Data)
reasoning.WriteString(r)
content.WriteString(c)
_, _ = fmt.Fprintf(w, "data: %s\n\n", injectCreatedChunk(ev.Data, created))
if flusher != nil {
flusher.Flush()
@@ -148,9 +151,13 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch
flusher.Flush()
}
completion := content.String()
if reasoning.Len() > 0 {
completion = "<think>\n" + reasoning.String() + "\n</think>\n\n" + completion
}
usage := stream.Usage()
a.Limiter.RecordTokens(entry.Address, usage.TotalTokens)
a.logRequest(r, key, entry, address, http.StatusOK, true, usage, "", start, ttft, messages, sb.String())
a.logRequest(r, key, entry, address, http.StatusOK, true, usage, "", start, ttft, messages, completion)
}
func applyOverrides(payload, overrides map[string]any) {
@@ -217,13 +224,16 @@ func marshalMessages(v any) json.RawMessage {
}
// extractCompletion pulls the first choice's message content from a non-streaming
// response body. reasoning_content (Qwen3/DeepSeek-R1 thinking) is prepended when present.
// response body. Reasoning (reasoning_content, reasoning, or reasoning_details) is
// wrapped in think tags and prepended when present.
func extractCompletion(body []byte) string {
var resp struct {
Choices []struct {
Message struct {
ReasoningContent string `json:"reasoning_content"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []reasoningDetail `json:"reasoning_details"`
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
@@ -231,31 +241,51 @@ func extractCompletion(body []byte) string {
return ""
}
m := resp.Choices[0].Message
if m.ReasoningContent != "" {
return "<think>\n" + m.ReasoningContent + "\n</think>\n" + m.Content
r := reasoningText(m.ReasoningContent, m.Reasoning, m.ReasoningDetails)
if r != "" {
return "<think>\n" + r + "\n</think>\n\n" + m.Content
}
return m.Content
}
// extractDelta pulls the assistant content delta from a streaming SSE data chunk.
// reasoning_content deltas are wrapped in <think> tags on first/last occurrence.
func extractDelta(data []byte) string {
// extractDelta pulls the reasoning and content deltas from a streaming SSE chunk.
// reasoning_content, reasoning, and reasoning_details are all recognized.
func extractDelta(data []byte) (reasoning, content string) {
var chunk struct {
Choices []struct {
Delta struct {
ReasoningContent string `json:"reasoning_content"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []reasoningDetail `json:"reasoning_details"`
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &chunk); err != nil || len(chunk.Choices) == 0 {
return ""
return "", ""
}
d := chunk.Choices[0].Delta
if d.ReasoningContent != "" {
return d.ReasoningContent
return reasoningText(d.ReasoningContent, d.Reasoning, d.ReasoningDetails), d.Content
}
// reasoningDetail is one entry in a reasoning_details array.
type reasoningDetail struct {
Type string `json:"type"`
Text string `json:"text"`
Summary string `json:"summary"`
}
// reasoningText joins reasoning fragments a provider may emit: reasoning_content
// (llama.cpp), reasoning (OpenRouter), and reasoning_details (structured blocks).
func reasoningText(reasoningContent, reasoning string, details []reasoningDetail) string {
var b strings.Builder
b.WriteString(reasoningContent)
b.WriteString(reasoning)
for _, d := range details {
b.WriteString(d.Text)
b.WriteString(d.Summary)
}
return d.Content
return b.String()
}
func clientIP(r *http.Request) string {

View File

@@ -0,0 +1,65 @@
package api
import (
"strings"
"testing"
)
func TestExtractCompletionReasoningContent(t *testing.T) {
body := []byte(`{"choices":[{"message":{"reasoning_content":"why","content":"ans"}}]}`)
got := extractCompletion(body)
want := "<think>\nwhy\n</think>\n\nans"
if got != want {
t.Errorf("got %q", got)
}
}
func TestExtractCompletionReasoning(t *testing.T) {
body := []byte(`{"choices":[{"message":{"reasoning":"thought","content":"reply"}}]}`)
got := extractCompletion(body)
if !strings.Contains(got, "thought") || !strings.Contains(got, "reply") {
t.Errorf("missing fragments: %q", got)
}
if strings.Index(got, "thought") > strings.Index(got, "reply") {
t.Errorf("reasoning must precede content: %q", got)
}
}
func TestExtractCompletionReasoningDetails(t *testing.T) {
body := []byte(`{"choices":[{"message":{"reasoning_details":[{"type":"reasoning.text","text":"abc"}],"content":"out"}}]}`)
got := extractCompletion(body)
if !strings.Contains(got, "abc") || !strings.Contains(got, "out") {
t.Errorf("missing fragments: %q", got)
}
}
func TestExtractCompletionNoReasoning(t *testing.T) {
body := []byte(`{"choices":[{"message":{"content":"only"}}]}`)
if got := extractCompletion(body); got != "only" {
t.Errorf("got %q", got)
}
}
func TestExtractDeltaReasoning(t *testing.T) {
data := []byte(`{"choices":[{"delta":{"reasoning":"th","content":""}}]}`)
r, c := extractDelta(data)
if r != "th" || c != "" {
t.Errorf("reasoning=%q content=%q", r, c)
}
}
func TestExtractDeltaContent(t *testing.T) {
data := []byte(`{"choices":[{"delta":{"content":"hi"}}]}`)
r, c := extractDelta(data)
if r != "" || c != "hi" {
t.Errorf("reasoning=%q content=%q", r, c)
}
}
func TestExtractDeltaReasoningDetails(t *testing.T) {
data := []byte(`{"choices":[{"delta":{"reasoning_details":[{"type":"reasoning.text","text":"x"}],"content":"y"}}]}`)
r, c := extractDelta(data)
if r != "x" || c != "y" {
t.Errorf("reasoning=%q content=%q", r, c)
}
}

View File

@@ -86,13 +86,21 @@ func (s *stream) process(data []byte) (ev providers.Event, emit, done bool) {
case "content_block_delta":
var d struct {
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
} `json:"delta"`
}
_ = json.Unmarshal(data, &d)
if d.Delta.Type == "text_delta" && d.Delta.Text != "" {
return s.contentChunk(d.Delta.Text), true, false
switch d.Delta.Type {
case "text_delta":
if d.Delta.Text != "" {
return s.contentChunk(d.Delta.Text), true, false
}
case "thinking_delta":
if d.Delta.Thinking != "" {
return s.reasoningChunk(d.Delta.Thinking), true, false
}
}
case "message_delta":
@@ -127,6 +135,15 @@ func (s *stream) contentChunk(text string) providers.Event {
return s.chunk(delta, nil, nil)
}
func (s *stream) reasoningChunk(text string) providers.Event {
delta := map[string]any{"reasoning_content": text}
if !s.roleSent {
delta["role"] = "assistant"
s.roleSent = true
}
return s.chunk(delta, nil, nil)
}
func (s *stream) finalChunk() providers.Event {
s.usage.TotalTokens = s.usage.PromptTokens + s.usage.CompletionTokens
finish := s.finishReason

View File

@@ -26,6 +26,7 @@ func TestStreamTranslation(t *testing.T) {
s := newStream(io.NopCloser(strings.NewReader(sse)), "claude")
var content strings.Builder
var reasoning strings.Builder
var finish string
var sawRole bool
for {
@@ -40,8 +41,9 @@ func TestStreamTranslation(t *testing.T) {
Object string `json:"object"`
Choices []struct {
Delta struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
@@ -57,6 +59,7 @@ func TestStreamTranslation(t *testing.T) {
sawRole = true
}
content.WriteString(chunk.Choices[0].Delta.Content)
reasoning.WriteString(chunk.Choices[0].Delta.ReasoningContent)
if chunk.Choices[0].FinishReason != nil {
finish = *chunk.Choices[0].FinishReason
}
@@ -76,3 +79,56 @@ func TestStreamTranslation(t *testing.T) {
t.Fatalf("usage = %+v", u)
}
}
func TestStreamThinking(t *testing.T) {
sse := strings.Join([]string{
`event: message_start`,
`data: {"type":"message_start","message":{"id":"msg_2","usage":{"input_tokens":4}}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"Let me "}}`,
``,
`data: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"think."}}`,
``,
`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Answer"}}`,
``,
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
``,
`data: {"type":"message_stop"}`,
``,
}, "\n")
s := newStream(io.NopCloser(strings.NewReader(sse)), "claude")
var content strings.Builder
var reasoning strings.Builder
for {
ev, err := s.Recv()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"delta"`
} `json:"choices"`
}
_ = json.Unmarshal(ev.Data, &chunk)
if len(chunk.Choices) > 0 {
content.WriteString(chunk.Choices[0].Delta.Content)
reasoning.WriteString(chunk.Choices[0].Delta.ReasoningContent)
}
}
if reasoning.String() != "Let me think." {
t.Errorf("reasoning = %q", reasoning.String())
}
if content.String() != "Answer" {
t.Errorf("content = %q", content.String())
}
}

View File

@@ -201,11 +201,12 @@ func translateResponse(body []byte, model string) ([]byte, providers.Usage) {
var ar struct {
ID string `json:"id"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
@@ -217,11 +218,14 @@ func translateResponse(body []byte, model string) ([]byte, providers.Usage) {
_ = json.Unmarshal(body, &ar)
var text strings.Builder
var thinking strings.Builder
var toolCalls []map[string]any
for _, c := range ar.Content {
switch c.Type {
case "text":
text.WriteString(c.Text)
case "thinking":
thinking.WriteString(c.Thinking)
case "tool_use":
args := "{}"
if len(c.Input) > 0 {
@@ -243,6 +247,9 @@ func translateResponse(body []byte, model string) ([]byte, providers.Usage) {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
message := map[string]any{"role": "assistant", "content": text.String()}
if thinking.Len() > 0 {
message["reasoning_content"] = thinking.String()
}
if len(toolCalls) > 0 {
message["tool_calls"] = toolCalls
}

View File

@@ -104,6 +104,45 @@ func TestTranslateResponse(t *testing.T) {
}
}
func TestTranslateResponseThinking(t *testing.T) {
anthropicBody := []byte(`{
"id": "msg_456",
"content": [
{"type":"thinking","thinking":"step by step"},
{"type":"text","text":"answer"}
],
"stop_reason": "end_turn",
"usage": {"input_tokens": 5, "output_tokens": 8}
}`)
body, usage := translateResponse(anthropicBody, "claude")
if usage.CompletionTokens != 8 {
t.Fatalf("usage = %+v", usage)
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Choices) != 1 {
t.Fatalf("choices = %+v", out.Choices)
}
if out.Choices[0].Message.Content != "answer" {
t.Errorf("content = %q", out.Choices[0].Message.Content)
}
if out.Choices[0].Message.ReasoningContent != "step by step" {
t.Errorf("reasoning_content = %q", out.Choices[0].Message.ReasoningContent)
}
}
func TestMapStopReason(t *testing.T) {
cases := map[string]string{
"end_turn": "stop",