Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/internal/application/channel/model_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

appbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/billing"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
)

func TestProtocolDefaultsForCompatibleOnlyIncludesSupportedPrimaryKinds(t *testing.T) {
Expand Down Expand Up @@ -203,6 +204,18 @@ func TestDetectModelVendorRecognizesCompanyVendors(t *testing.T) {
}
}

func TestReasoningContentPassbackRequiredForDeepSeekChatCompletions(t *testing.T) {
if !reasoningContentPassbackRequired(llm.AdapterOpenAIChatCompletions, "deepseek", "deepseek-v4-flash-free") {
t.Fatal("expected DeepSeek Chat Completions route to require reasoning_content passback")
}
if reasoningContentPassbackRequired(llm.AdapterOpenAIChatCompletions, "openai", "gpt-5.4") {
t.Fatal("expected OpenAI Chat Completions route to skip reasoning_content passback")
}
if reasoningContentPassbackRequired(llm.AdapterOpenAIResponses, "deepseek", "deepseek-v4-flash-free") {
t.Fatal("expected non Chat Completions route to skip reasoning_content passback")
}
}

func TestNormalizeModelIconSeparatesVendorAndModelFamily(t *testing.T) {
tests := map[string]struct {
vendor string
Expand Down
1 change: 1 addition & 0 deletions backend/internal/application/channel/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type ResolvedRoute struct {
ModelCapabilitiesJSON string
ModelSystemPrompt string
UpstreamModel string
ReasoningContentPassback bool
UpstreamCbFailureThreshold int
UpstreamCbModelThreshold int
UpstreamCbThresholdLogic string
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/application/channel/service_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,13 @@ func normalizeUpstreamModelVendor(raw string, candidates ...string) string {
return "unknown"
}

func reasoningContentPassbackRequired(protocol string, candidates ...string) bool {
if llm.NormalizeAdapter(protocol) != llm.AdapterOpenAIChatCompletions {
return false
}
return detectModelVendor(candidates...) == "deepseek"
}

func detectModelVendor(candidates ...string) string {
fallback := ""
for _, candidate := range candidates {
Expand Down
1 change: 1 addition & 0 deletions backend/internal/application/channel/service_routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ func buildResolvedRoute(row repository.ChannelUpstreamRouteRow, apiKey string) *
ModelCapabilitiesJSON: strings.TrimSpace(row.ModelCapabilitiesJSON),
ModelSystemPrompt: strings.TrimSpace(row.ModelSystemPrompt),
UpstreamModel: strings.TrimSpace(row.UpstreamModelName),
ReasoningContentPassback: reasoningContentPassbackRequired(row.Protocol, row.ModelVendor, row.PlatformModelName, row.UpstreamModelName, row.UpstreamName),
UpstreamCbFailureThreshold: row.UpstreamCbFailureThreshold,
UpstreamCbModelThreshold: row.UpstreamCbModelThreshold,
UpstreamCbThresholdLogic: row.UpstreamCbThresholdLogic,
Expand Down
34 changes: 25 additions & 9 deletions backend/internal/application/conversation/service_message_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,14 @@ func (s *Service) sendMessageInternal(
}
streamRequested := preferStream && onDelta != nil
streamSupported := llm.SupportsStreamingAdapter(routeConfig.Protocol)
var callVisibleText strings.Builder
emitCallVisibleDelta := func(delta string) error {
if err := emitVisibleDelta(delta); err != nil {
return err
}
callVisibleText.WriteString(delta)
return nil
}
callPromptShape := summarizePromptShape(callPromptMode, currentInput.Messages, currentInput.Messages, currentInput.PreviousResponseID)
generationCtx, generationSpan := platformtracing.Start(ctx, "conversation.llm.generate",
trace.WithAttributes(append([]attribute.KeyValue{
Expand All @@ -750,7 +758,7 @@ func (s *Service) sendMessageInternal(
if output == nil || (strings.TrimSpace(output.Text) == "" && output.Reasoning == nil) {
return nil
}
cleanText, thinkText := splitThinkingContent(output.Text)
cleanText, thinkText := splitAssistantOutputThinkingContent(output.Text)
if traceRecorder != nil && output.Reasoning != nil {
traceRecorder.syncStructuredThink(
output.Reasoning.Text,
Expand All @@ -769,10 +777,10 @@ func (s *Service) sendMessageInternal(
if traceRecorder != nil {
traceRecorder.completeUpstreamThink()
}
if cleanText == "" {
cleanText = output.Text
if cleanText == "" && strings.TrimSpace(thinkText) == "" {
cleanText = strings.TrimSpace(output.Text)
}
if streamErr := emitVisibleDelta(cleanText); streamErr != nil {
if streamErr := emitCallVisibleDelta(cleanText); streamErr != nil {
return streamErr
}
output.Text = cleanText
Expand Down Expand Up @@ -836,7 +844,7 @@ func (s *Service) sendMessageInternal(
if visibleDelta == "" {
return nil
}
return emitVisibleDelta(visibleDelta)
return emitCallVisibleDelta(visibleDelta)
})
generateErr = streamErr
if generateErr == nil {
Expand All @@ -861,10 +869,13 @@ func (s *Service) sendMessageInternal(
traceRecorder.completeUpstreamThink()
}
if visibleTail != "" {
if tailErr := emitVisibleDelta(visibleTail); tailErr != nil {
if tailErr := emitCallVisibleDelta(visibleTail); tailErr != nil {
generateErr = tailErr
}
}
if output != nil {
output.Text = callVisibleText.String()
}
}
if generateErr != nil && shouldFallbackToNonStreaming(generateErr) {
output, generateErr = s.llmClient.Generate(generationCtx, routeConfig, currentInput)
Expand Down Expand Up @@ -1013,11 +1024,16 @@ func (s *Service) sendMessageInternal(
if len(toolResult.ToolResults) == 0 {
break
}
reasoningContent := ""
if route.ReasoningContentPassback {
reasoningContent = outputReasoningContent(upstreamOutput)
}
llmMessages = append(llmMessages,
llm.Message{
Role: "assistant",
Content: assistantText,
ToolCalls: toolResult.ExecutedToolCalls,
Role: "assistant",
Content: assistantText,
ReasoningContent: reasoningContent,
ToolCalls: toolResult.ExecutedToolCalls,
},
llm.Message{
Role: "tool",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func buildPromptStateFingerprint(input promptStateFingerprintInput) string {
writeFingerprintField(hasher, "part_data", hex.EncodeToString(dataHash[:]))
}
}
writeFingerprintField(hasher, "reasoning_content", message.ReasoningContent)
for _, call := range message.ToolCalls {
writeFingerprintField(hasher, "tool_call_id", call.ToolCallID)
writeFingerprintField(hasher, "tool_call_type", call.ToolType)
Expand Down
19 changes: 16 additions & 3 deletions backend/internal/application/conversation/service_tool_loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ func syncUpstreamOutputThinking(traceRecorder *messageTraceRecorder, output *llm
if output == nil {
return ""
}
assistantText, extractedThink := splitThinkingContent(output.Text)
if assistantText == "" {
assistantText = output.Text
assistantText, extractedThink := splitAssistantOutputThinkingContent(output.Text)
if assistantText == "" && strings.TrimSpace(extractedThink) == "" {
assistantText = strings.TrimSpace(output.Text)
}
if traceRecorder != nil && output.Reasoning != nil {
traceRecorder.syncStructuredThink(
Expand Down Expand Up @@ -53,6 +53,19 @@ func syncUpstreamOutputTrace(traceRecorder *messageTraceRecorder, output *llm.Ge
return assistantText, serverToolRows
}

func outputReasoningContent(output *llm.GenerateOutput) string {
if output == nil {
return ""
}
if output.Reasoning != nil {
if text := strings.TrimSpace(output.Reasoning.Text); text != "" {
return text
}
}
_, extractedThink := splitAssistantOutputThinkingContent(output.Text)
return strings.TrimSpace(extractedThink)
}

func shouldSyncServerToolsBeforeThinking(output *llm.GenerateOutput) bool {
return output != nil && len(output.ServerToolCalls) > 0 && len(output.ToolCalls) == 0
}
Expand Down
34 changes: 34 additions & 0 deletions backend/internal/application/conversation/service_tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,37 @@ func TestAddServerSideToolUsageAggregatesPositiveCounts(t *testing.T) {
t.Fatalf("expected non-positive usage to be ignored: %#v", got)
}
}

func TestSyncUpstreamOutputThinkingDoesNotReturnThinkingOnlyContent(t *testing.T) {
output := &llm.GenerateOutput{
Text: "<think>Need to call a tool.</think>",
ToolCalls: []llm.ToolCall{{
ToolCallID: "call_1",
ToolType: "function",
ToolName: "memory.list",
ArgumentsJSON: "{}",
Status: "requested",
}},
}

if got := syncUpstreamOutputThinking(nil, output); got != "" {
t.Fatalf("expected thinking-only tool call content to stay out of assistant text, got %q", got)
}
}

func TestOutputReasoningContentPrefersStructuredReasoning(t *testing.T) {
output := &llm.GenerateOutput{
Reasoning: &llm.ReasoningOutput{Text: "need a tool"},
Text: "<think>fallback</think>",
}

got := outputReasoningContent(output)
if got != "need a tool" {
t.Fatalf("expected structured reasoning content, got %q", got)
}

got = outputReasoningContent(&llm.GenerateOutput{Text: "<think>fallback</think>"})
if got != "fallback" {
t.Fatalf("expected parsed thinking fallback, got %q", got)
}
}
15 changes: 15 additions & 0 deletions backend/internal/application/conversation/service_trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,21 @@ func splitThinkingContent(content string) (string, string) {
return strings.TrimSpace(visible), strings.TrimSpace(think)
}

func splitAssistantOutputThinkingContent(content string) (string, string) {
_, tagName, openEnd, openPending, ok := parseLeadingThinkingOpenTag(content)
if openPending {
return strings.TrimSpace(content), ""
}
if !ok {
return strings.TrimSpace(content), ""
}
closeStart, closeEnd, found := findThinkingCloseTag(content, openEnd, tagName)
if !found {
return "", strings.TrimSpace(content[openEnd:])
}
return strings.TrimSpace(content[closeEnd:]), strings.TrimSpace(content[openEnd:closeStart])
}

func splitLeadingThinkingBlock(content string, flush bool) (visible string, think string, pending bool) {
if content == "" {
return "", "", false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,43 @@ func TestSplitThinkingContentOnlyAcceptsLeadingClosedBlock(t *testing.T) {
}
}

func TestSplitAssistantOutputThinkingContentRemovesProtocolUnsafeThinking(t *testing.T) {
tests := []struct {
name string
input string
wantVisible string
wantThink string
}{
{
name: "closed leading think block",
input: "<think>hidden</think>visible",
wantVisible: "visible",
wantThink: "hidden",
},
{
name: "unclosed leading think block",
input: "<thinking>tool decision",
wantVisible: "",
wantThink: "tool decision",
},
{
name: "plain visible content",
input: "visible <think>literal</think>",
wantVisible: "visible <think>literal</think>",
wantThink: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
visible, think := splitAssistantOutputThinkingContent(tt.input)
if visible != tt.wantVisible || think != tt.wantThink {
t.Fatalf("unexpected split: visible=%q think=%q", visible, think)
}
})
}
}

func TestThinkingDeltaRouterParsesEachAssistantOutputStart(t *testing.T) {
router := &thinkingDeltaRouter{}
visible, think := router.consume("<thi")
Expand Down
26 changes: 14 additions & 12 deletions backend/internal/infra/llm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,13 @@ type CacheControl struct {
// Message 定义发送给上游的消息结构。
// Parts 非空时覆盖 Content 用于多模态内容。
type Message struct {
Role string
Content string // 纯文本消息内容(Parts 为空时使用)
Parts []ContentPart // 多模态内容片段(设置后优先于 Content)
ToolCalls []ToolCall // assistant 请求执行的工具调用
ToolResults []ToolResult // 工具执行结果,用于回灌下一轮模型调用
CacheControl *CacheControl // 支持块级缓存的 adapter 可读取该提示
Role string
Content string // 纯文本消息内容(Parts 为空时使用)
Parts []ContentPart // 多模态内容片段(设置后优先于 Content)
ReasoningContent string // OpenAI-compatible thinking mode 的 reasoning_content 回灌字段
ToolCalls []ToolCall // assistant 请求执行的工具调用
ToolResults []ToolResult // 工具执行结果,用于回灌下一轮模型调用
CacheControl *CacheControl // 支持块级缓存的 adapter 可读取该提示
}

// GenerateInput 定义上游推理请求入参。
Expand Down Expand Up @@ -873,12 +874,13 @@ func normalizeMessages(messages []Message) []Message {
continue
}
normalized = append(normalized, Message{
Role: normalizeRole(item.Role),
Content: item.Content,
Parts: item.Parts,
ToolCalls: item.ToolCalls,
ToolResults: item.ToolResults,
CacheControl: item.CacheControl,
Role: normalizeRole(item.Role),
Content: item.Content,
Parts: item.Parts,
ReasoningContent: item.ReasoningContent,
ToolCalls: item.ToolCalls,
ToolResults: item.ToolResults,
CacheControl: item.CacheControl,
})
}
if len(normalized) == 0 {
Expand Down
Loading
Loading