diff --git a/model/channel.go b/model/channel.go index 0f8cdb101ec8..83c455006580 100644 --- a/model/channel.go +++ b/model/channel.go @@ -985,6 +985,14 @@ func (channel *Channel) ValidateSettings() error { return err } } + if err := channelOtherSettings.ContentToReasoning.Validate(); err != nil { + return err + } + if channelOtherSettings.ContentToReasoning != nil && + channelOtherSettings.ContentToReasoning.Enabled && + channelParams.ThinkingToContent { + return fmt.Errorf("thinking_to_content and content_to_reasoning cannot both be enabled") + } if channel.Type == constant.ChannelTypeAdvancedCustom && channelOtherSettings.UpstreamModelUpdateCheckEnabled { if _, ok := channelOtherSettings.AdvancedCustom.ModelListRoute(); !ok { return fmt.Errorf("advanced custom channels require a %s route when upstream model update checks are enabled", dto.AdvancedCustomModelListPath) diff --git a/model/channel_settings_test.go b/model/channel_settings_test.go index 7612e697080f..390e4fd9bda0 100644 --- a/model/channel_settings_test.go +++ b/model/channel_settings_test.go @@ -98,3 +98,93 @@ func TestAdvancedCustomChannelRequiresModelListRouteOnlyWhenUpdateChecksEnabled( }) } } + +func TestContentToReasoningSettingsValidation(t *testing.T) { + tests := []struct { + name string + channel *Channel + wantErr string + }{ + { + name: "enabled with default markers", + channel: func() *Channel { + channel := &Channel{} + channel.SetOtherSettings(dto.ChannelOtherSettings{ + ContentToReasoning: &dto.ContentToReasoningSettings{Enabled: true}, + }) + return channel + }(), + }, + { + name: "enabled with paired markers", + channel: func() *Channel { + channel := &Channel{} + channel.SetOtherSettings(dto.ChannelOtherSettings{ + ContentToReasoning: &dto.ContentToReasoningSettings{ + Enabled: true, + Markers: []dto.ContentToReasoningMarkerPair{ + {Start: "", End: ""}, + {Start: "[think]", End: "[/think]"}, + }, + }, + }) + return channel + }(), + }, + { + name: "incomplete marker rejected", + channel: func() *Channel { + channel := &Channel{} + channel.SetOtherSettings(dto.ChannelOtherSettings{ + ContentToReasoning: &dto.ContentToReasoningSettings{ + Enabled: true, + Markers: []dto.ContentToReasoningMarkerPair{ + {Start: "", End: ""}, + }, + }, + }) + return channel + }(), + wantErr: "both start and end", + }, + { + name: "disabled with invalid marker is tolerated", + channel: func() *Channel { + channel := &Channel{} + channel.SetOtherSettings(dto.ChannelOtherSettings{ + ContentToReasoning: &dto.ContentToReasoningSettings{ + Enabled: false, + Markers: []dto.ContentToReasoningMarkerPair{ + {Start: "", End: ""}, + }, + }, + }) + return channel + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.channel.ValidateSettings() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestContentToReasoningConflictsWithThinkingToContent(t *testing.T) { + channel := &Channel{} + channel.SetSetting(dto.ChannelSettings{ThinkingToContent: true}) + channel.SetOtherSettings(dto.ChannelOtherSettings{ + ContentToReasoning: &dto.ContentToReasoningSettings{Enabled: true}, + }) + + err := channel.ValidateSettings() + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot both be enabled") +} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 2f424b32abdd..ea95017126f7 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -242,6 +242,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud switch info.RelayFormat { case types.RelayFormatOpenAI: openaiResponse := ResponseClaude2OpenAI(&claudeResponse) + info.TransformContentToReasoningFull(openaiResponse) openaiResponse.Usage = buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage) responseData, err = common.Marshal(openaiResponse) if err != nil { diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 84acea73c585..f6190de1fbb5 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -297,6 +297,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * if err != nil { return usage, err } + openai.FlushContentToReasoning(c, info) response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage) if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil && !info.ClaudeConvertInfo.Done { @@ -363,6 +364,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R usage := buildUsageFromGeminiResponse(c, info, &geminiResponse) fullTextResponse.Usage = usage + info.TransformContentToReasoningFull(fullTextResponse) switch info.RelayFormat { case types.RelayFormatOpenAI: diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 666235ff5633..4737ad71de74 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -21,8 +21,29 @@ import ( // 辅助函数 func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { + if info.ContentToReasoningEnabled() { + responses, err := info.TransformContentToReasoningStream(data) + if err != nil { + return err + } + for _, response := range responses { + responseData, err := common.Marshal(response) + if err != nil { + return err + } + info.SendResponseCount++ + if err := handleStreamFormat(c, info, string(responseData), forceFormat, false); err != nil { + return err + } + } + return nil + } + info.SendResponseCount++ + return handleStreamFormat(c, info, data, forceFormat, thinkToContent) +} +func handleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { switch info.RelayFormat { case types.RelayFormatOpenAI: return sendStreamData(c, info, data, forceFormat, thinkToContent) @@ -34,6 +55,24 @@ func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string return nil } +// FlushContentToReasoning emits buffered unclosed reasoning after the upstream +// stream has ended. +func FlushContentToReasoning(c *gin.Context, info *relaycommon.RelayInfo) { + if info == nil || !info.ContentToReasoningEnabled() { + return + } + responses, _ := info.ContentToReasoningFlush() + for _, response := range responses { + responseData, err := common.Marshal(response) + if err != nil { + continue + } + if err := handleStreamFormat(c, info, string(responseData), info.ChannelSetting.ForceFormat, false); err != nil { + common.SysLog("error flushing content_to_reasoning: " + err.Error()) + } + } +} + func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { var streamResponse dto.ChatCompletionsStreamResponse if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 9a0619eb27f5..4748ce6f2aef 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -174,10 +174,16 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re if info.RelayFormat == types.RelayFormatOpenAI { if shouldSendLastResp { - _ = sendStreamData(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + if info.ContentToReasoningEnabled() { + _ = HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + } else { + _ = sendStreamData(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + } } } + FlushContentToReasoning(c, info) + if !containStreamUsage { usage = service.ResponseText2Usage(c, responseTextBuilder.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) usage.CompletionTokens += toolCount * 7 @@ -266,6 +272,8 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo } } + c2rChanged := info.TransformContentToReasoningFull(&simpleResponse) + forceFormat := false if info.ChannelSetting.ForceFormat { forceFormat = true @@ -301,7 +309,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo bodyMap["usage"] = simpleResponse.Usage responseBody, _ = common.Marshal(bodyMap) } - if forceFormat { + if forceFormat || c2rChanged { responseBody, err = common.Marshal(simpleResponse) if err != nil { return nil, types.NewError(err, types.ErrorCodeBadResponseBody) diff --git a/relay/common/content_to_reasoning.go b/relay/common/content_to_reasoning.go new file mode 100644 index 000000000000..30199556126f --- /dev/null +++ b/relay/common/content_to_reasoning.go @@ -0,0 +1,311 @@ +package common + +import ( + "sort" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/relayconvert/content2reasoning" +) + +const ( + DefaultContentToReasoningStart = "" + DefaultContentToReasoningEnd = "" +) + +// ContentToReasoningSession keeps per-choice parser state for one relay response. +type ContentToReasoningSession struct { + markers []content2reasoning.Pair + states map[int]*content2reasoning.State + lastID string + lastCreated int64 + lastModel string + lastFingerprint *string + flushed bool +} + +type contentToReasoningMetadataChoice struct { + index int + choice dto.ChatCompletionsStreamResponseChoice +} + +func defaultContentToReasoningMarkers() []content2reasoning.Pair { + return []content2reasoning.Pair{ + {Start: DefaultContentToReasoningStart, End: DefaultContentToReasoningEnd}, + } +} + +func (info *RelayInfo) ContentToReasoningEnabled() bool { + if info == nil || info.ChannelMeta == nil { + return false + } + if info.ChannelSetting.ThinkingToContent { + return false + } + setting := info.ChannelOtherSettings.ContentToReasoning + return setting != nil && setting.Enabled +} + +func (info *RelayInfo) ensureContentToReasoningSession() (*ContentToReasoningSession, error) { + if info.ContentToReasoningSession != nil { + return info.ContentToReasoningSession, nil + } + var markers []content2reasoning.Pair + setting := info.ChannelOtherSettings.ContentToReasoning + if setting == nil { + markers = defaultContentToReasoningMarkers() + } else if len(setting.Markers) == 0 { + markers = defaultContentToReasoningMarkers() + } else { + markers = make([]content2reasoning.Pair, len(setting.Markers)) + for i, marker := range setting.Markers { + markers[i] = content2reasoning.Pair{Start: marker.Start, End: marker.End} + } + } + session := &ContentToReasoningSession{ + markers: markers, + states: make(map[int]*content2reasoning.State), + } + info.ContentToReasoningSession = session + return session, nil +} + +func (info *RelayInfo) contentToReasoningState(index int) (*content2reasoning.State, error) { + session, err := info.ensureContentToReasoningSession() + if err != nil { + return nil, err + } + if state := session.states[index]; state != nil { + return state, nil + } + state, err := content2reasoning.NewState(session.markers) + if err != nil { + return nil, err + } + session.states[index] = state + return state, nil +} + +// TransformContentToReasoningStream converts one OpenAI stream response. When no +// marker state is active and no marker is present, the original response is +// returned unchanged. Metadata-only events are passed through untouched. +func (info *RelayInfo) TransformContentToReasoningStream(data string) ([]*dto.ChatCompletionsStreamResponse, error) { + var stream dto.ChatCompletionsStreamResponse + if err := common.UnmarshalJsonStr(data, &stream); err != nil { + return nil, err + } + + session, err := info.ensureContentToReasoningSession() + if err != nil { + return nil, err + } + session.lastID = stream.Id + session.lastCreated = stream.Created + session.lastModel = stream.Model + session.lastFingerprint = stream.SystemFingerprint + + type plan struct { + fragments []content2reasoning.Fragment + } + plans := make(map[int]plan, len(stream.Choices)) + tailChoices := make([]contentToReasoningMetadataChoice, 0, len(stream.Choices)) + + for _, choice := range stream.Choices { + text := choice.Delta.GetContentString() + alreadyStructured := choice.Delta.GetReasoningContent() != "" + if text == "" || alreadyStructured { + tailChoices = append(tailChoices, contentToReasoningMetadataChoice{index: choice.Index, choice: choice}) + continue + } + + state, err := info.contentToReasoningState(choice.Index) + if err != nil { + return nil, err + } + fragments := state.Feed(text) + if len(fragments) == 0 { + // Mid-marker: keep metadata, suppress the buffered content. + metadataOnly := choice + metadataOnly.Delta.Content = nil + tailChoices = append(tailChoices, contentToReasoningMetadataChoice{index: choice.Index, choice: metadataOnly}) + continue + } + plans[choice.Index] = plan{fragments: fragments} + } + + maxFragments := 0 + for _, item := range plans { + if len(item.fragments) > maxFragments { + maxFragments = len(item.fragments) + } + } + + if maxFragments == 0 { + if len(tailChoices) == 0 { + if stream.Usage == nil { + return nil, nil + } + response := newStreamResponseShell(&stream) + response.Usage = stream.Usage + return []*dto.ChatCompletionsStreamResponse{response}, nil + } + response := newStreamResponseShell(&stream) + response.Choices = choicesFromMetadata(tailChoices) + response.Usage = stream.Usage + return []*dto.ChatCompletionsStreamResponse{response}, nil + } + + outputs := make([]*dto.ChatCompletionsStreamResponse, 0, maxFragments) + for ordinal := 0; ordinal < maxFragments; ordinal++ { + response := newStreamResponseShell(&stream) + response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0, len(stream.Choices)) + + for i := range stream.Choices { + item, planned := plans[stream.Choices[i].Index] + if !planned || ordinal >= len(item.fragments) { + continue + } + choice := stream.Choices[i] + choice.Delta = cleanStreamDelta(choice.Delta) + if ordinal == 0 { + choice.Delta.Role = stream.Choices[i].Delta.Role + } + applyStreamFragment(&choice, item.fragments[ordinal]) + if ordinal == len(item.fragments)-1 { + applyStreamChoiceMetadata(&choice, stream.Choices[i]) + } + response.Choices = append(response.Choices, choice) + } + + if ordinal == maxFragments-1 { + response.Choices = append(response.Choices, choicesFromMetadata(tailChoices)...) + response.Usage = stream.Usage + } + if len(response.Choices) > 0 { + outputs = append(outputs, response) + } + } + + return outputs, nil +} + +// ContentToReasoningFlush emits any state buffered because a reasoning marker had +// not closed before the stream ended. It is a no-op after the first call. +func (info *RelayInfo) ContentToReasoningFlush() ([]*dto.ChatCompletionsStreamResponse, bool) { + if info == nil || info.ContentToReasoningSession == nil { + return nil, false + } + session := info.ContentToReasoningSession + if session.flushed { + return nil, false + } + session.flushed = true + + indexes := make([]int, 0, len(session.states)) + for index := range session.states { + indexes = append(indexes, index) + } + sort.Ints(indexes) + + outputs := make([]*dto.ChatCompletionsStreamResponse, 0, len(indexes)) + for _, index := range indexes { + state := session.states[index] + fragments, unclosed := state.Done() + if !unclosed && len(fragments) == 0 { + continue + } + response := dto.ChatCompletionsStreamResponse{ + Id: session.lastID, + Object: "chat.completion.chunk", + Created: session.lastCreated, + Model: session.lastModel, + SystemFingerprint: session.lastFingerprint, + Choices: make([]dto.ChatCompletionsStreamResponseChoice, 0, len(fragments)), + } + for _, fragment := range fragments { + choice := dto.ChatCompletionsStreamResponseChoice{ + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{}, + Index: index, + } + applyStreamFragment(&choice, fragment) + response.Choices = append(response.Choices, choice) + } + if len(response.Choices) > 0 { + outputs = append(outputs, &response) + } + } + return outputs, len(outputs) > 0 +} + +// TransformContentToReasoningFull mutates a non-streaming OpenAI response. +func (info *RelayInfo) TransformContentToReasoningFull(response *dto.OpenAITextResponse) bool { + if response == nil || info == nil || !info.ContentToReasoningEnabled() { + return false + } + session, err := info.ensureContentToReasoningSession() + if err != nil { + return false + } + _ = session + + changed := false + for i := range response.Choices { + message := &response.Choices[i].Message + content := message.StringContent() + if content == "" || message.GetReasoningContent() != "" { + continue + } + if !message.IsStringContent() { + continue + } + result := content2reasoning.SplitText(content, session.markers) + if !result.Found { + continue + } + message.SetStringContent(result.Content) + if result.Reasoning != "" { + reasoning := result.Reasoning + message.ReasoningContent = &reasoning + message.Reasoning = nil + } + changed = true + } + return changed +} + +func newStreamResponseShell(source *dto.ChatCompletionsStreamResponse) *dto.ChatCompletionsStreamResponse { + return &dto.ChatCompletionsStreamResponse{ + Id: source.Id, + Object: source.Object, + Created: source.Created, + Model: source.Model, + SystemFingerprint: source.SystemFingerprint, + } +} + +func cleanStreamDelta(delta dto.ChatCompletionsStreamResponseChoiceDelta) dto.ChatCompletionsStreamResponseChoiceDelta { + return dto.ChatCompletionsStreamResponseChoiceDelta{} +} + +func applyStreamFragment(choice *dto.ChatCompletionsStreamResponseChoice, fragment content2reasoning.Fragment) { + switch fragment.Kind { + case content2reasoning.KindThinking: + choice.Delta.SetReasoningContent(fragment.Text) + case content2reasoning.KindContent: + choice.Delta.SetContentString(fragment.Text) + } +} + +func applyStreamChoiceMetadata(target *dto.ChatCompletionsStreamResponseChoice, source dto.ChatCompletionsStreamResponseChoice) { + target.FinishReason = source.FinishReason + target.Logprobs = source.Logprobs + target.Delta.ToolCalls = source.Delta.ToolCalls +} + +func choicesFromMetadata(choices []contentToReasoningMetadataChoice) []dto.ChatCompletionsStreamResponseChoice { + result := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(choices)) + for _, item := range choices { + result = append(result, item.choice) + } + return result +} diff --git a/relay/common/content_to_reasoning_test.go b/relay/common/content_to_reasoning_test.go new file mode 100644 index 000000000000..378d6bb0730e --- /dev/null +++ b/relay/common/content_to_reasoning_test.go @@ -0,0 +1,166 @@ +package common + +import ( + "testing" + + commonutil "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newContentToReasoningTestInfo(markers []dto.ContentToReasoningMarkerPair) *RelayInfo { + info := &RelayInfo{ChannelMeta: &ChannelMeta{}} + info.ChannelOtherSettings.ContentToReasoning = &dto.ContentToReasoningSettings{ + Enabled: true, + Markers: markers, + } + return info +} + +func strPtr(value string) *string { + return &value +} + +func marshalContentToReasoningStream(t *testing.T, stream dto.ChatCompletionsStreamResponse) string { + t.Helper() + data, err := commonutil.Marshal(stream) + require.NoError(t, err) + return string(data) +} + +func requireTextValue(t *testing.T, value *string, want string) { + t.Helper() + require.NotNil(t, value) + assert.Equal(t, want, *value) +} + +func TestTransformContentToReasoningStreamSplitsAcrossChunks(t *testing.T) { + info := newContentToReasoningTestInfo(nil) + + responses, err := info.TransformContentToReasoningStream(marshalContentToReasoningStream(t, dto.ChatCompletionsStreamResponse{ + Id: "chat-1", + Created: 1, + Model: "test", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + Content: strPtr("visibleanswer"), + }, + }, + }, + })) + require.NoError(t, err) + require.Len(t, responses, 2) + + first := responses[0].Choices[0] + requireTextValue(t, first.Delta.ReasoningContent, "visible") + assert.Nil(t, first.Delta.Content) + + second := responses[1].Choices[0] + requireTextValue(t, second.Delta.Content, "answer") + assert.Nil(t, second.Delta.ReasoningContent) +} + +func TestTransformContentToReasoningStreamPassesThroughStructuredContent(t *testing.T) { + info := newContentToReasoningTestInfo(nil) + + content := "already structured" + reasoning := "precomputed" + responses, err := info.TransformContentToReasoningStream(marshalContentToReasoningStream(t, dto.ChatCompletionsStreamResponse{ + Id: "chat-2", + Created: 2, + Model: "test", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Content: &content, + ReasoningContent: &reasoning, + }, + }, + }, + })) + require.NoError(t, err) + require.Len(t, responses, 1) + require.Len(t, responses[0].Choices, 1) + + got := responses[0].Choices[0].Delta + requireTextValue(t, got.Content, content) + requireTextValue(t, got.ReasoningContent, reasoning) +} + +func TestContentToReasoningFlushEmitsUnclosedReasoning(t *testing.T) { + info := newContentToReasoningTestInfo(nil) + + _, err := info.TransformContentToReasoningStream(marshalContentToReasoningStream(t, dto.ChatCompletionsStreamResponse{ + Id: "chat-3", + Created: 3, + Model: "test", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Content: strPtr("unfinished"), + }, + }, + }, + })) + require.NoError(t, err) + + responses, flushed := info.ContentToReasoningFlush() + assert.True(t, flushed) + require.Len(t, responses, 1) + require.Len(t, responses[0].Choices, 1) + requireTextValue(t, responses[0].Choices[0].Delta.ReasoningContent, "unfinished") +} + +func TestTransformContentToReasoningStreamPassthroughUsageOnlyChunk(t *testing.T) { + info := newContentToReasoningTestInfo(nil) + usage := &dto.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15} + + responses, err := info.TransformContentToReasoningStream(marshalContentToReasoningStream(t, dto.ChatCompletionsStreamResponse{ + Id: "chat-4", + Created: 4, + Model: "test", + Usage: usage, + })) + require.NoError(t, err) + require.Len(t, responses, 1) + require.Empty(t, responses[0].Choices) + require.NotNil(t, responses[0].Usage) + assert.Equal(t, 15, responses[0].Usage.TotalTokens) +} + +func TestTransformContentToReasoningStreamDropsEmptyChunkWithoutUsage(t *testing.T) { + info := newContentToReasoningTestInfo(nil) + + responses, err := info.TransformContentToReasoningStream(marshalContentToReasoningStream(t, dto.ChatCompletionsStreamResponse{ + Id: "chat-5", + Created: 5, + Model: "test", + })) + require.NoError(t, err) + assert.Empty(t, responses) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index b0bb19bdca3b..c411911fe660 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -183,6 +183,7 @@ type RelayInfo struct { *ResponsesUsageInfo *ChannelMeta *TaskRelayInfo + *ContentToReasoningSession } func (info *RelayInfo) InitChannelMeta(c *gin.Context) { @@ -245,6 +246,7 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) { if info.Request != nil { info.Request.SetModelName(info.OriginModelName) } + info.ContentToReasoningSession = nil } func (info *RelayInfo) ToString() string { diff --git a/relaykit/dto/channel_settings.go b/relaykit/dto/channel_settings.go index 4b4e71911283..a121001f095f 100644 --- a/relaykit/dto/channel_settings.go +++ b/relaykit/dto/channel_settings.go @@ -77,6 +77,7 @@ type ChannelOtherSettings struct { DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护) DisableTaskPollingSleep bool `json:"disable_task_polling_sleep,omitempty"` // 是否跳过异步任务轮询间隔 + ContentToReasoning *ContentToReasoningSettings `json:"content_to_reasoning,omitempty"` AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新 UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新 @@ -87,6 +88,34 @@ type ChannelOtherSettings struct { AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"` } +// ContentToReasoningSettings enables extracting marker-delimited reasoning text +// from upstream content. Empty Markers uses the caller's default marker pair. +type ContentToReasoningSettings struct { + Enabled bool `json:"enabled"` + Markers []ContentToReasoningMarkerPair `json:"markers,omitempty"` +} + +// ContentToReasoningMarkerPair is one paired start/end marker. Start and End must +// both be non-empty. +type ContentToReasoningMarkerPair struct { + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` +} + +// Validate checks marker pairs at save time. A disabled or nil setting is always +// valid; when enabled, any explicitly configured pair must be complete. +func (s *ContentToReasoningSettings) Validate() error { + if s == nil || !s.Enabled { + return nil + } + for i, marker := range s.Markers { + if strings.TrimSpace(marker.Start) == "" || strings.TrimSpace(marker.End) == "" { + return fmt.Errorf("content_to_reasoning.markers[%d] must have both start and end", i) + } + } + return nil +} + func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { if s == nil || s.OpenRouterEnterprise == nil { return false diff --git a/relaykit/relayconvert/content2reasoning/parser.go b/relaykit/relayconvert/content2reasoning/parser.go new file mode 100644 index 000000000000..b53c27beeb96 --- /dev/null +++ b/relaykit/relayconvert/content2reasoning/parser.go @@ -0,0 +1,298 @@ +// Package content2reasoning parses reasoning markers embedded in streamed or +// buffered text. It is intentionally protocol agnostic: callers feed text in, +// receive ordered fragments out, and are responsible for mapping fragments onto +// their response DTOs. +package content2reasoning + +import ( + "fmt" + "strings" +) + +// Pair is one start/end marker pair. Start and End must be non-empty after +// trimming. When Start equals End the marker toggles: the first occurrence opens +// a reasoning block and the next closes it. +type Pair struct { + Start string + End string +} + +type Kind int + +const ( + KindContent Kind = iota + KindThinking +) + +type Fragment struct { + Kind Kind + Text string +} + +type phase int + +const ( + phaseAwaiting phase = iota + phaseThinking + phaseContent +) + +// State parses one choice across arbitrary chunk boundaries. It is not safe for +// concurrent use. +type State struct { + markers []Pair + phase phase + active int + + tail string + knowledge strings.Builder + found bool +} + +// NewState validates markers and returns a parser ready to consume the first chunk. +func NewState(markers []Pair) (*State, error) { + if len(markers) == 0 { + return nil, fmt.Errorf("content2reasoning: at least one marker pair is required") + } + normalized := make([]Pair, len(markers)) + for i, marker := range markers { + start := strings.TrimSpace(marker.Start) + end := strings.TrimSpace(marker.End) + if start == "" || end == "" { + return nil, fmt.Errorf("content2reasoning: marker[%d] start and end must not be empty", i) + } + normalized[i] = Pair{Start: start, End: end} + } + return &State{ + markers: normalized, + phase: phaseAwaiting, + }, nil +} + +// Feed consumes text and returns fragments that can be emitted immediately. +func (s *State) Feed(text string) []Fragment { + if s == nil || text == "" { + return nil + } + if s.phase == phaseContent { + return []Fragment{{Kind: KindContent, Text: text}} + } + + s.tail += text + if s.phase == phaseThinking { + return s.consumeThinking() + } + return s.consumeAwaiting() +} + +// IsContent reports whether the parser has already emitted its first complete +// reasoning block and is now passing text through untouched. +func (s *State) IsContent() bool { + return s != nil && s.phase == phaseContent +} + +// IsThinking reports whether a reasoning block is currently open. +func (s *State) IsThinking() bool { + return s != nil && s.phase == phaseThinking +} + +// Found reports whether at least one reasoning block has been entered. +func (s *State) Found() bool { + return s != nil && s.found +} + +// Done flushes the parser. If a reasoning block is still open, the collected text +// is emitted as reasoning and unclosed is true. Any buffered partial end marker is +// trimmed so the marker itself does not leak into reasoning. +func (s *State) Done() ([]Fragment, bool) { + if s == nil { + return nil, false + } + switch s.phase { + case phaseThinking: + trimmed := s.tail + if partial := longestPartialSuffix(s.tail, s.activeEnd()); partial > 0 { + trimmed = s.tail[:len(s.tail)-partial] + } + reasoning := s.knowledge.String() + trimmed + s.tail = "" + s.knowledge.Reset() + s.phase = phaseContent + if reasoning == "" { + return nil, true + } + return []Fragment{{Kind: KindThinking, Text: reasoning}}, true + case phaseAwaiting: + content := s.tail + s.tail = "" + s.phase = phaseContent + if content == "" { + return nil, false + } + return []Fragment{{Kind: KindContent, Text: content}}, false + default: + return nil, false + } +} + +func (s *State) consumeAwaiting() []Fragment { + var fragments []Fragment + for s.phase == phaseAwaiting { + index, markerIndex := earliestStart(s.tail, s.markers) + if index < 0 { + keep := longestPartialPrefix(s.tail, startMarkers(s.markers)) + if end := len(s.tail) - keep; end > 0 { + fragments = append(fragments, Fragment{Kind: KindContent, Text: s.tail[:end]}) + s.tail = s.tail[end:] + } + return fragments + } + + if index > 0 { + fragments = append(fragments, Fragment{Kind: KindContent, Text: s.tail[:index]}) + } + s.active = markerIndex + s.found = true + s.tail = s.tail[index+len(s.markers[markerIndex].Start):] + s.knowledge.Reset() + s.phase = phaseThinking + + fragments = append(fragments, s.consumeThinking()...) + } + return fragments +} + +func (s *State) consumeThinking() []Fragment { + var fragments []Fragment + if s.phase != phaseThinking { + return fragments + } + + endMarker := s.activeEnd() + index := strings.Index(s.tail, endMarker) + if index >= 0 { + reasoning := s.knowledge.String() + s.tail[:index] + rest := s.tail[index+len(endMarker):] + s.tail = "" + s.knowledge.Reset() + s.phase = phaseContent + if reasoning != "" { + fragments = append(fragments, Fragment{Kind: KindThinking, Text: reasoning}) + } + if rest != "" { + fragments = append(fragments, Fragment{Kind: KindContent, Text: rest}) + } + return fragments + } + + // Keep only a trailing slice that may be the start of the end marker. + keep := longestPartialSuffix(s.tail, endMarker) + if end := len(s.tail) - keep; end > 0 { + s.knowledge.WriteString(s.tail[:end]) + s.tail = s.tail[end:] + } + return fragments +} + +func (s *State) activeEnd() string { + if s == nil || s.active < 0 || s.active >= len(s.markers) { + return "" + } + return s.markers[s.active].End +} + +func startMarkers(markers []Pair) []string { + starts := make([]string, len(markers)) + for i := range markers { + starts[i] = markers[i].Start + } + return starts +} + +func earliestStart(text string, markers []Pair) (int, int) { + bestIndex := -1 + bestMarker := -1 + for i := range markers { + index := strings.Index(text, markers[i].Start) + if index < 0 { + continue + } + if bestIndex < 0 || index < bestIndex || (index == bestIndex && i < bestMarker) { + bestIndex = index + bestMarker = i + } + } + return bestIndex, bestMarker +} + +// longestPartialPrefix returns the length of the longest non-empty suffix of text +// that is a strict prefix of one of the candidate strings. +func longestPartialPrefix(text string, candidates []string) int { + best := 0 + for _, candidate := range candidates { + if candidate == "" { + continue + } + best = max(best, longestPartialSuffix(text, candidate)) + } + return best +} + +// longestPartialSuffix returns the length of the longest non-empty suffix of text +// that is a strict prefix of marker. +func longestPartialSuffix(text string, marker string) int { + if marker == "" { + return 0 + } + limit := len(text) + if limit > len(marker)-1 { + limit = len(marker) - 1 + } + for length := limit; length > 0; length-- { + if strings.HasPrefix(marker, text[len(text)-length:]) { + return length + } + } + return 0 +} + +type SplitResult struct { + Reasoning string + Content string + Found bool + Unclosed bool +} + +// SplitText parses a complete text buffer with the same semantics as State. +func SplitText(text string, markers []Pair) SplitResult { + result := SplitResult{} + if len(markers) == 0 { + return result + } + state, err := NewState(markers) + if err != nil { + return result + } + for _, fragment := range state.Feed(text) { + result.append(fragment) + } + done, unclosed := state.Done() + for _, fragment := range done { + if fragment.Kind == KindThinking { + result.Found = true + } + result.append(fragment) + } + result.Found = state.Found() + result.Unclosed = unclosed + return result +} + +func (r *SplitResult) append(fragment Fragment) { + switch fragment.Kind { + case KindThinking: + r.Reasoning += fragment.Text + case KindContent: + r.Content += fragment.Text + } +} diff --git a/relaykit/relayconvert/content2reasoning/parser_test.go b/relaykit/relayconvert/content2reasoning/parser_test.go new file mode 100644 index 000000000000..4f28e628ef99 --- /dev/null +++ b/relaykit/relayconvert/content2reasoning/parser_test.go @@ -0,0 +1,149 @@ +package content2reasoning + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSplitTextCompleteBlock(t *testing.T) { + result := SplitText("分析过程最终答案", []Pair{{Start: "", End: ""}}) + + assert.Equal(t, "分析过程", result.Reasoning) + assert.Equal(t, "最终答案", result.Content) + assert.True(t, result.Found) + assert.False(t, result.Unclosed) +} + +func TestSplitTextPrefixAndSuffix(t *testing.T) { + result := SplitText("prefixreasoningsuffix", []Pair{{Start: "", End: ""}}) + + assert.Equal(t, "reasoning", result.Reasoning) + assert.Equal(t, "prefixsuffix", result.Content) +} + +func TestSplitTextPlainTextIsUntouched(t *testing.T) { + result := SplitText("no markers here", []Pair{{Start: "", End: ""}}) + + assert.Equal(t, "", result.Reasoning) + assert.Equal(t, "no markers here", result.Content) + assert.False(t, result.Found) +} + +func TestStateStreamAcrossChunks(t *testing.T) { + state, err := NewState([]Pair{{Start: "", End: ""}}) + require.NoError(t, err) + + assert.Empty(t, state.Feed("abc")) + assert.Empty(t, state.Feed("defanswer"), + ) + + done, unclosed := state.Done() + require.False(t, unclosed) + assert.Empty(t, done) +} + +func TestStateSingleChunkMixedContent(t *testing.T) { + state, err := NewState([]Pair{{Start: "", End: ""}}) + require.NoError(t, err) + + fragments := state.Feed("prefixreasoningsuffix") + assert.Equal(t, []Fragment{ + {Kind: KindContent, Text: "prefix"}, + {Kind: KindThinking, Text: "reasoning"}, + {Kind: KindContent, Text: "suffix"}, + }, fragments) +} + +func TestStateFlushesPrefixBeforeMarker(t *testing.T) { + state, err := NewState([]Pair{{Start: "", End: ""}}) + require.NoError(t, err) + + assert.Equal(t, []Fragment{{Kind: KindContent, Text: "wait "}}, state.Feed("wait ")) +} + +func TestStateOptimisticUnclosedReasoning(t *testing.T) { + state, err := NewState([]Pair{{Start: "", End: ""}}) + require.NoError(t, err) + + assert.Empty(t, state.Feed("abc")) + done, unclosed := state.Done() + assert.True(t, unclosed) + assert.Equal(t, []Fragment{{Kind: KindThinking, Text: "abc"}}, done) +} + +func TestStateUnclosedTrimsPartialEndMarker(t *testing.T) { + state, err := NewState([]Pair{{Start: "", End: ""}}) + require.NoError(t, err) + + assert.Empty(t, state.Feed("abc", End: ""}}) + require.NoError(t, err) + + _ = state.Feed("a") + assert.Equal(t, []Fragment{{Kind: KindContent, Text: "tail literal"}}, state.Feed("tail literal")) + assert.True(t, state.IsContent()) + assert.True(t, state.Found()) +} + +func TestStateSameStartAndEndToggles(t *testing.T) { + state, err := NewState([]Pair{{Start: "```", End: "```"}}) + require.NoError(t, err) + + assert.Equal(t, []Fragment{ + {Kind: KindThinking, Text: "think"}, + {Kind: KindContent, Text: "answer"}, + }, state.Feed("```think```answer")) + assert.True(t, state.IsContent()) +} + +func TestStateMultiplePairsUsesEarliestStart(t *testing.T) { + state, err := NewState([]Pair{ + {Start: "", End: ""}, + {Start: "", End: ""}, + }) + require.NoError(t, err) + + fragments := state.Feed("xinnery") + assert.Equal(t, []Fragment{ + {Kind: KindContent, Text: "x"}, + {Kind: KindThinking, Text: "inner"}, + {Kind: KindContent, Text: "y"}, + }, fragments) +} + +func TestStateMultiplePairsUnclosedActiveEnd(t *testing.T) { + state, err := NewState([]Pair{ + {Start: "", End: ""}, + {Start: "", End: ""}, + }) + require.NoError(t, err) + + assert.Empty(t, state.Feed("abc")) + done, unclosed := state.Done() + assert.True(t, unclosed) + assert.Equal(t, []Fragment{{Kind: KindThinking, Text: "abc"}}, done) +} + +func TestNewStateRejectsInvalidPairs(t *testing.T) { + _, err := NewState(nil) + require.Error(t, err) + + _, err = NewState([]Pair{{Start: "", End: ""}}) + require.Error(t, err) +} diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 56a4e950d6f7..b7f47642a5db 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -286,6 +286,8 @@ const SENSITIVE_FORM_FIELDS = [ 'azure_responses_version', 'force_format', 'thinking_to_content', + 'content_to_reasoning_enabled', + 'content_to_reasoning_markers', 'proxy', 'http_protocol', 'http2_connection_shards', @@ -342,6 +344,8 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { values.system_prompt?.trim() || values.force_format || values.thinking_to_content || + values.content_to_reasoning_enabled || + values.content_to_reasoning_markers?.trim() || values.pass_through_body_enabled || values.system_prompt_override || (values.http_protocol && values.http_protocol !== 'auto') || @@ -748,6 +752,12 @@ export function ChannelMutateDrawer({ const currentHeaderOverride = form.watch('header_override') const currentForceFormat = form.watch('force_format') const currentThinkingToContent = form.watch('thinking_to_content') + const currentContentToReasoningEnabled = form.watch( + 'content_to_reasoning_enabled' + ) + const currentContentToReasoningMarkers = form.watch( + 'content_to_reasoning_markers' + ) const currentPassThroughBodyEnabled = form.watch('pass_through_body_enabled') const currentDisableTaskPollingSleep = form.watch( 'disable_task_polling_sleep' @@ -1020,6 +1030,8 @@ export function ChannelMutateDrawer({ const extraSettingsConfigured = Boolean( currentForceFormat || currentThinkingToContent || + currentContentToReasoningEnabled || + currentContentToReasoningMarkers?.trim() || currentPassThroughBodyEnabled || currentDisableTaskPollingSleep || currentProxy?.trim() || @@ -4119,8 +4131,63 @@ export function ChannelMutateDrawer({ + + + )} + /> + + ( + +
+
+ + {t('Content to Reasoning')} + + + {t( + 'Convert marker-delimited thinking text into reasoning_content' + )} + +
+ + + +
+ {field.value && ( + ( + + + {t('Markers')} + + +