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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
65
backend/internal/api/chat_test.go
Normal file
65
backend/internal/api/chat_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user