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')}
+
+
+ "}]'
+ )}
+ {...markersField}
+ />
+
+
+ {t(
+ 'JSON array of paired start and end markers. Leave empty to use the default markers.'
+ )}
+
+
+
+ )}
/>
-
+ )}
)}
/>
@@ -4559,11 +4626,11 @@ export function ChannelMutateDrawer({
/>
- )}
- />
- )}
+ )}
+ />
+ )}
- (
diff --git a/web/src/features/channels/lib/channel-form-errors.ts b/web/src/features/channels/lib/channel-form-errors.ts
index 92716038462e..f190bcfd2016 100644
--- a/web/src/features/channels/lib/channel-form-errors.ts
+++ b/web/src/features/channels/lib/channel-form-errors.ts
@@ -37,6 +37,8 @@ const ADVANCED_SETTINGS_FIELDS = new Set>([
'advanced_custom',
'force_format',
'thinking_to_content',
+ 'content_to_reasoning_enabled',
+ 'content_to_reasoning_markers',
'pass_through_body_enabled',
'proxy',
'http_protocol',
diff --git a/web/src/features/channels/lib/channel-form.ts b/web/src/features/channels/lib/channel-form.ts
index 244014cb334c..5119b25f75a8 100644
--- a/web/src/features/channels/lib/channel-form.ts
+++ b/web/src/features/channels/lib/channel-form.ts
@@ -122,6 +122,34 @@ function isOptionalJsonObject(value: string | undefined): boolean {
}
}
+function parseContentToReasoningMarkers(
+ value: string | undefined
+): { start: string; end: string }[] | undefined {
+ if (!value?.trim()) return undefined
+ try {
+ const parsed = JSON.parse(value)
+ if (!Array.isArray(parsed)) return undefined
+ const markers = parsed.map((item) => {
+ if (typeof item !== 'object' || item === null) return null
+ const start = String(item.start || '').trim()
+ const end = String(item.end || '').trim()
+ if (!start || !end) return null
+ return { start, end }
+ })
+ if (markers.some((item) => item === null)) return undefined
+ return markers as { start: string; end: string }[]
+ } catch {
+ return undefined
+ }
+}
+
+function isOptionalContentToReasoningMarkers(
+ value: string | undefined
+): boolean {
+ if (!value?.trim()) return true
+ return parseContentToReasoningMarkers(value) !== undefined
+}
+
function isOptionalModelMapping(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
@@ -255,6 +283,14 @@ export const channelFormSchema = z
// Channel extra settings (stored in setting JSON, not sent directly)
force_format: z.boolean().optional(),
thinking_to_content: z.boolean().optional(),
+ content_to_reasoning_enabled: z.boolean().optional(),
+ content_to_reasoning_markers: z
+ .string()
+ .optional()
+ .refine(
+ isOptionalContentToReasoningMarkers,
+ 'Markers must be a JSON array of objects with start and end strings'
+ ),
proxy: z
.string()
.optional()
@@ -430,6 +466,8 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
// Channel extra settings
force_format: false,
thinking_to_content: false,
+ content_to_reasoning_enabled: false,
+ content_to_reasoning_markers: '',
proxy: '',
http_protocol: HTTP_PROTOCOL_AUTO,
http2_connection_shards: 1,
@@ -518,6 +556,8 @@ export function transformChannelToFormDefaults(
let upstreamModelUpdateAutoSyncEnabled = false
let upstreamModelUpdateIgnoredModels = ''
let advancedCustom = ''
+ let contentToReasoningEnabled = false
+ let contentToReasoningMarkers = ''
if (channel.settings) {
try {
@@ -546,6 +586,24 @@ export function transformChannelToFormDefaults(
if (parsed.advanced_custom) {
advancedCustom = stringifyAdvancedCustomConfig(parsed.advanced_custom)
}
+ if (
+ parsed.content_to_reasoning &&
+ typeof parsed.content_to_reasoning === 'object'
+ ) {
+ const contentToReasoning = parsed.content_to_reasoning as Record<
+ string,
+ unknown
+ >
+ contentToReasoningEnabled = contentToReasoning.enabled === true
+ if (
+ Array.isArray(contentToReasoning.markers) &&
+ contentToReasoning.markers.length > 0
+ ) {
+ contentToReasoningMarkers = JSON.stringify(
+ contentToReasoning.markers
+ )
+ }
+ }
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse channel settings:', error)
@@ -597,6 +655,8 @@ export function transformChannelToFormDefaults(
upstream_model_update_auto_sync_enabled: upstreamModelUpdateAutoSyncEnabled,
upstream_model_update_ignored_models: upstreamModelUpdateIgnoredModels,
advanced_custom: advancedCustom,
+ content_to_reasoning_enabled: contentToReasoningEnabled,
+ content_to_reasoning_markers: contentToReasoningMarkers,
}
}
@@ -726,6 +786,17 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
settingsObj.disable_task_polling_sleep =
formData.disable_task_polling_sleep === true
+ if (formData.content_to_reasoning_enabled) {
+ const markers = parseContentToReasoningMarkers(
+ formData.content_to_reasoning_markers
+ )
+ settingsObj.content_to_reasoning = markers
+ ? { enabled: true, markers }
+ : { enabled: true }
+ } else if ('content_to_reasoning' in settingsObj) {
+ delete settingsObj.content_to_reasoning
+ }
+
// Upstream model update settings (for model-fetchable channel types)
if (MODEL_FETCHABLE_TYPES.has(formData.type)) {
settingsObj.upstream_model_update_check_enabled =
diff --git a/web/src/features/channels/types.ts b/web/src/features/channels/types.ts
index 6b53c336f238..930a07b1a17a 100644
--- a/web/src/features/channels/types.ts
+++ b/web/src/features/channels/types.ts
@@ -109,6 +109,17 @@ export interface ChannelOtherSettings {
upstream_model_update_last_check_time?: number
upstream_model_update_last_detected_models?: string[]
advanced_custom?: AdvancedCustomConfig
+ content_to_reasoning?: ContentToReasoningSettings
+}
+
+export interface ContentToReasoningSettings {
+ enabled: boolean
+ markers?: ContentToReasoningMarkerPair[]
+}
+
+export interface ContentToReasoningMarkerPair {
+ start: string
+ end: string
}
export interface AdvancedCustomConfig {
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 86095b29c1b3..f396a63894ea 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -28,6 +28,7 @@
"[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]": "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
+ "[{\"start\":\"\",\"end\":\"\"}]": "[{\"start\":\"\",\"end\":\"\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "{{category}} Models",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} completed",
@@ -1065,6 +1066,7 @@
"Content displayed on the home page (supports Markdown)": "Content displayed on the home page (supports Markdown)",
"Content not found.": "Content not found.",
"Content not modified!": "Content not modified!",
+ "Content to Reasoning": "Content to Reasoning",
"Content width": "Content width",
"Context": "Context",
"Continue": "Continue",
@@ -1082,6 +1084,7 @@
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Controls whether user verification (biometrics/PIN) is required during Passkey flows.",
"Conversation cleared": "Conversation cleared",
"Conversion rate from USD to your custom currency": "Conversion rate from USD to your custom currency",
+ "Convert marker-delimited thinking text into reasoning_content": "Convert marker-delimited thinking text into reasoning_content",
"Convert reasoning_content to tag in content": "Convert reasoning_content to tag in content",
"Convert string to lowercase": "Convert string to lowercase",
"Convert string to uppercase": "Convert string to uppercase",
@@ -2429,6 +2432,7 @@
"Jina": "Jina",
"JSON": "JSON",
"JSON array of group identifiers. When enabled below, new tokens rotate through this list.": "JSON array of group identifiers. When enabled below, new tokens rotate through this list.",
+ "JSON array of paired start and end markers. Leave empty to use the default markers.": "JSON array of paired start and end markers. Leave empty to use the default markers.",
"JSON Editor": "JSON Editor",
"JSON format error": "JSON format error",
"JSON format supports service account JSON files": "JSON format supports service account JSON files",
@@ -2617,6 +2621,8 @@
"Map request model names to actual provider model names (JSON format)": "Map request model names to actual provider model names (JSON format)",
"Map response status codes (JSON format)": "Map response status codes (JSON format)",
"Map upstream status codes to different codes": "Map upstream status codes to different codes",
+ "Markers": "Markers",
+ "Markers must be a JSON array of objects with start and end strings": "Markers must be a JSON array of objects with start and end strings",
"Market Share": "Market Share",
"Marketing": "Marketing",
"Master instances run scheduled background tasks.": "Master instances run scheduled background tasks.",
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index 1d4dc4b9550d..ffc922e8ed54 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -28,6 +28,7 @@
"[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]": "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
+ "[{\"start\":\"\",\"end\":\"\"}]": "[{\"start\":\"\",\"end\":\"\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "Modèles {{category}}",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} terminé(s)",
@@ -1065,6 +1066,7 @@
"Content displayed on the home page (supports Markdown)": "Contenu affiché sur la page d'accueil (prend en charge Markdown)",
"Content not found.": "Contenu non trouvé.",
"Content not modified!": "Contenu non modifié !",
+ "Content to Reasoning": "Contenu vers raisonnement",
"Content width": "Largeur du contenu",
"Context": "Contexte",
"Continue": "Continuer",
@@ -1082,6 +1084,7 @@
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Contrôle si la vérification de l'utilisateur (biométrie/PIN) est requise lors des flux de Passkey.",
"Conversation cleared": "Conversation effacée",
"Conversion rate from USD to your custom currency": "Taux de conversion de l'USD vers votre devise personnalisée",
+ "Convert marker-delimited thinking text into reasoning_content": "Convertir la réflexion encadrée par des balises en reasoning_content",
"Convert reasoning_content to tag in content": "Convertir reasoning_content en balise dans content",
"Convert string to lowercase": "Convertir la chaîne en minuscules",
"Convert string to uppercase": "Convertir la chaîne en majuscules",
@@ -2429,6 +2432,7 @@
"Jina": "Jina",
"JSON": "JSON",
"JSON array of group identifiers. When enabled below, new tokens rotate through this list.": "Tableau JSON d'identifiants de groupe. Lorsqu'il est activé ci-dessous, les nouveaux jetons alternent dans cette liste.",
+ "JSON array of paired start and end markers. Leave empty to use the default markers.": "Tableau JSON des marqueurs de début et de fin. Laissez vide pour utiliser les marqueurs par défaut.",
"JSON Editor": "Édition JSON",
"JSON format error": "Erreur de format JSON",
"JSON format supports service account JSON files": "Le format JSON prend en charge les fichiers JSON de compte de service",
@@ -2617,6 +2621,8 @@
"Map request model names to actual provider model names (JSON format)": "Mapper les noms de modèles de requête aux noms réels de modèles du fournisseur (format JSON)",
"Map response status codes (JSON format)": "Mapper les codes de statut de réponse (format JSON)",
"Map upstream status codes to different codes": "Mapper les codes de statut amont à différents codes",
+ "Markers": "Marqueurs",
+ "Markers must be a JSON array of objects with start and end strings": "Les marqueurs doivent être un tableau JSON d’objets avec des chaînes start et end",
"Market Share": "Part de marché",
"Marketing": "Marketing",
"Master instances run scheduled background tasks.": "Les instances master exécutent les tâches planifiées en arrière-plan.",
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index 7eaed9574c18..494857258b6e 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -28,6 +28,7 @@
"[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]": "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
+ "[{\"start\":\"\",\"end\":\"\"}]": "[{\"start\":\"\",\"end\":\"\"}]",
"{\"original-model\": \"replacement-model\"}": "{\" original - model \":\" replacement - model \"}",
"{{category}} Models": "{{category}} モデル",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} 完了",
@@ -1065,6 +1066,7 @@
"Content displayed on the home page (supports Markdown)": "ホームページに表示されるコンテンツ(Markdownをサポート)",
"Content not found.": "コンテンツが見つかりません。",
"Content not modified!": "コンテンツが変更されていません!",
+ "Content to Reasoning": "内容を推論へ変換",
"Content width": "コンテンツ幅",
"Context": "コンテキスト",
"Continue": "続行",
@@ -1082,6 +1084,7 @@
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Passkeyフロー中にユーザー認証(生体認証/PIN)が必要かどうかを制御します。",
"Conversation cleared": "会話を消去しました",
"Conversion rate from USD to your custom currency": "USDからカスタム通貨への換算レート",
+ "Convert marker-delimited thinking text into reasoning_content": "タグで区切られた思考内容を reasoning_content に変換する",
"Convert reasoning_content to tag in content": "content内のreasoning_contentをタグに変換",
"Convert string to lowercase": "文字列を小文字に変換",
"Convert string to uppercase": "文字列を大文字に変換",
@@ -2429,6 +2432,7 @@
"Jina": "Jina",
"JSON": "JSON",
"JSON array of group identifiers. When enabled below, new tokens rotate through this list.": "グループ識別子のJSON配列。以下で有効にすると、新しいトークンはこのリストをローテーションします。",
+ "JSON array of paired start and end markers. Leave empty to use the default markers.": "開始と終了のタグのペアを JSON 配列で指定します。空の場合は既定のタグを使用します。",
"JSON Editor": "JSON編集",
"JSON format error": "JSONフォーマットエラー",
"JSON format supports service account JSON files": "JSON形式はサービスアカウントJSONファイルをサポートします",
@@ -2617,6 +2621,8 @@
"Map request model names to actual provider model names (JSON format)": "リクエストのモデル名を実際のプロバイダーのモデル名にマッピング (JSON 形式)",
"Map response status codes (JSON format)": "応答ステータスコードをマッピング(JSON形式)",
"Map upstream status codes to different codes": "アップストリームのステータスコードを別のコードにマッピングする",
+ "Markers": "タグ",
+ "Markers must be a JSON array of objects with start and end strings": "Markers は start と end 文字列を持つオブジェクトの JSON 配列である必要があります",
"Market Share": "マーケットシェア",
"Marketing": "マーケティング",
"Master instances run scheduled background tasks.": "master インスタンスはスケジュールされたバックグラウンドタスクを実行します。",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 9e087d1fa53f..cff0b5679fb4 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -28,6 +28,7 @@
"[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]": "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
+ "[{\"start\":\"\",\"end\":\"\"}]": "[{\"start\":\"\",\"end\":\"\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "Модели {{category}}",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} завершено",
@@ -1065,6 +1066,7 @@
"Content displayed on the home page (supports Markdown)": "Содержимое, отображаемое на главной странице (поддерживает Markdown)",
"Content not found.": "Контент не найден.",
"Content not modified!": "Контент не изменён!",
+ "Content to Reasoning": "Контент в рассуждения",
"Content width": "Ширина контента",
"Context": "Контекст",
"Continue": "Продолжить",
@@ -1082,6 +1084,7 @@
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Определяет, требуется ли проверка пользователя (биометрия/PIN) во время процессов Passkey.",
"Conversation cleared": "Диалог очищен",
"Conversion rate from USD to your custom currency": "Курс конвертации из USD в вашу пользовательскую валюту",
+ "Convert marker-delimited thinking text into reasoning_content": "Преобразовать текст размышлений в тегах в reasoning_content",
"Convert reasoning_content to tag in content": "Преобразовать reasoning_content в тег в content",
"Convert string to lowercase": "Преобразовать строку в нижний регистр",
"Convert string to uppercase": "Преобразовать строку в верхний регистр",
@@ -2429,6 +2432,7 @@
"Jina": "Jina",
"JSON": "JSON",
"JSON array of group identifiers. When enabled below, new tokens rotate through this list.": "Массив JSON идентификаторов групп. Если включено ниже, новые токены будут циклически перебираться по этому списку.",
+ "JSON array of paired start and end markers. Leave empty to use the default markers.": "JSON-массив пар начального и конечного маркеров. Оставьте пустым для маркеров по умолчанию.",
"JSON Editor": "Редактирование JSON",
"JSON format error": "Ошибка формата JSON",
"JSON format supports service account JSON files": "Формат JSON поддерживает JSON-файлы сервисного аккаунта",
@@ -2617,6 +2621,8 @@
"Map request model names to actual provider model names (JSON format)": "Сопоставление имён моделей запроса реальным именам моделей провайдера (формат JSON)",
"Map response status codes (JSON format)": "Сопоставить коды статусов ответа (JSON-формат)",
"Map upstream status codes to different codes": "Сопоставить коды статуса вышестоящего сервера с различными кодами",
+ "Markers": "Маркеры",
+ "Markers must be a JSON array of objects with start and end strings": "Маркеры должны быть JSON-массивом объектов со строками start и end",
"Market Share": "Доля рынка",
"Marketing": "Маркетинг",
"Master instances run scheduled background tasks.": "Экземпляры master выполняют плановые фоновые задачи.",
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 2ae592388174..b36806361197 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -28,6 +28,7 @@
"[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]": "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
+ "[{\"start\":\"\",\"end\":\"\"}]": "[{\"start\":\"\",\"end\":\"\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "Mô hình {{category}}",
"{{completed}}/{{total}} completed": "Đã hoàn tất {{completed}}/{{total}}",
@@ -1065,6 +1066,7 @@
"Content displayed on the home page (supports Markdown)": "Nội dung hiển thị trên trang chủ (hỗ trợ Markdown)",
"Content not found.": "Không tìm thấy nội dung.",
"Content not modified!": "Nội dung không được thay đổi!",
+ "Content to Reasoning": "Nội dung thành nội dung lập luận",
"Content width": "Chiều rộng nội dung",
"Context": "Ngữ cảnh",
"Continue": "Tiếp tục",
@@ -1082,6 +1084,7 @@
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Kiểm soát xem liệu có yêu cầu xác minh người dùng (sinh trắc học/mã PIN) trong các luồng Passkey hay không.",
"Conversation cleared": "Đã xóa cuộc trò chuyện",
"Conversion rate from USD to your custom currency": "Tỷ giá chuyển đổi từ USD sang đơn vị tiền tệ tùy chỉnh của bạn",
+ "Convert marker-delimited thinking text into reasoning_content": "Chuyển văn bản suy nghĩ nằm giữa các thẻ đánh dấu thành reasoning_content",
"Convert reasoning_content to tag in content": "Chuyển đổi reasoning_content thành thẻ trong nội dung",
"Convert string to lowercase": "Chuyển chuỗi sang chữ thường",
"Convert string to uppercase": "Chuyển chuỗi sang chữ hoa",
@@ -2429,6 +2432,7 @@
"Jina": "Jina",
"JSON": "JSON",
"JSON array of group identifiers. When enabled below, new tokens rotate through this list.": "Mảng JSON của các định danh nhóm. Khi được bật bên dưới, các token mới sẽ luân phiên qua danh sách này.",
+ "JSON array of paired start and end markers. Leave empty to use the default markers.": "Nhập mảng JSON các cặp thẻ bắt đầu và kết thúc. Để trống để dùng thẻ mặc định.",
"JSON Editor": "Trình chỉnh sửa JSON",
"JSON format error": "Lỗi định dạng JSON",
"JSON format supports service account JSON files": "Định dạng JSON hỗ trợ các tệp JSON tài khoản dịch vụ",
@@ -2617,6 +2621,8 @@
"Map request model names to actual provider model names (JSON format)": "Ánh xạ tên mô hình yêu cầu đến tên mô hình thực tế của nhà cung cấp (định dạng JSON)",
"Map response status codes (JSON format)": "Ánh xạ mã trạng thái phản hồi (định dạng JSON)",
"Map upstream status codes to different codes": "Ánh xạ mã trạng thái upstream sang các mã khác",
+ "Markers": "Thẻ đánh dấu",
+ "Markers must be a JSON array of objects with start and end strings": "Markers phải là mảng JSON gồm các đối tượng có chuỗi start và end",
"Market Share": "Thị phần",
"Marketing": "Tiếp thị",
"Master instances run scheduled background tasks.": "Phiên bản master chạy các tác vụ nền theo lịch.",
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index 25250e24c7f5..8a6ef64b3494 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -28,6 +28,7 @@
"[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]": "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"支付寶\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"支付寶\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
+ "[{\"start\":\"\",\"end\":\"\"}]": "[{\"start\":\"\",\"end\":\"\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "{{category}} 模型",
"{{completed}}/{{total}} completed": "已完成 {{completed}}/{{total}}",
@@ -1065,6 +1066,7 @@
"Content displayed on the home page (supports Markdown)": "主頁上顯示的內容(支援 Markdown)",
"Content not found.": "內容未找到。",
"Content not modified!": "內容未修改!",
+ "Content to Reasoning": "內容轉推理內容",
"Content width": "內容寬度",
"Context": "上下文",
"Continue": "繼續",
@@ -1082,6 +1084,7 @@
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "控制在通行金鑰流程中是否需要用戶驗證(生物識別/PIN)。",
"Conversation cleared": "對話已清空",
"Conversion rate from USD to your custom currency": "從美元到您的自訂貨幣的轉換率",
+ "Convert marker-delimited thinking text into reasoning_content": "將帶標記的思考內容解析到 reasoning_content",
"Convert reasoning_content to tag in content": "將 reasoning_content 轉換為 content 中的 標籤",
"Convert string to lowercase": "把字串轉成小寫",
"Convert string to uppercase": "把字串轉成大寫",
@@ -2429,6 +2432,7 @@
"Jina": "Jina",
"JSON": "JSON",
"JSON array of group identifiers. When enabled below, new tokens rotate through this list.": "分組標識符的 JSON 陣列。在下方啟用時,新令牌將在此清單中輪換。",
+ "JSON array of paired start and end markers. Leave empty to use the default markers.": "以 JSON 陣列填寫成對的開始與結束標記,留空使用預設標記。",
"JSON Editor": "JSON 編輯",
"JSON format error": "JSON 格式錯誤",
"JSON format supports service account JSON files": "JSON 格式支援服務用戶 JSON 檔案",
@@ -2617,6 +2621,8 @@
"Map request model names to actual provider model names (JSON format)": "將請求模型名稱映射到實際供應商模型名稱 (JSON 格式)",
"Map response status codes (JSON format)": "映射回應狀態碼(JSON 格式)",
"Map upstream status codes to different codes": "將上游狀態碼映射到不同的代碼",
+ "Markers": "標記",
+ "Markers must be a JSON array of objects with start and end strings": "標記必須是包含 start 和 end 字串的物件 JSON 陣列",
"Market Share": "市場份額",
"Marketing": "市場營銷",
"Master instances run scheduled background tasks.": "master 實例執行排程背景任務。",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 457b1c53f2b0..655bfd51df24 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -28,6 +28,7 @@
"[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]": "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]": "[{\"name\":\"支付宝\",\"type\":\"alipay\",\"icon\":\"SiAlipay\"}]",
+ "[{\"start\":\"\",\"end\":\"\"}]": "[{\"start\":\"\",\"end\":\"\"}]",
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "{{category}} 模型",
"{{completed}}/{{total}} completed": "已完成 {{completed}}/{{total}}",
@@ -1065,6 +1066,7 @@
"Content displayed on the home page (supports Markdown)": "主页上显示的内容(支持 Markdown)",
"Content not found.": "内容未找到。",
"Content not modified!": "内容未修改!",
+ "Content to Reasoning": "内容转推理内容",
"Content width": "内容宽度",
"Context": "上下文",
"Continue": "继续",
@@ -1082,6 +1084,7 @@
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "控制在通行密钥流程中是否需要用户验证(生物识别/PIN)。",
"Conversation cleared": "对话已清空",
"Conversion rate from USD to your custom currency": "从美元到您的自定义货币的转换率",
+ "Convert marker-delimited thinking text into reasoning_content": "将带标记的思考内容解析到 reasoning_content",
"Convert reasoning_content to tag in content": "将 reasoning_content 转换为 content 中的 标签",
"Convert string to lowercase": "把字符串转成小写",
"Convert string to uppercase": "把字符串转成大写",
@@ -2429,6 +2432,7 @@
"Jina": "Jina",
"JSON": "JSON",
"JSON array of group identifiers. When enabled below, new tokens rotate through this list.": "分组标识符的 JSON 数组。在下方启用时,新令牌将在此列表中轮换。",
+ "JSON array of paired start and end markers. Leave empty to use the default markers.": "以 JSON 数组填写成对的开始和结束标记,留空使用默认标记。",
"JSON Editor": "JSON 编辑",
"JSON format error": "JSON 格式错误",
"JSON format supports service account JSON files": "JSON 格式支持服务账户 JSON 文件",
@@ -2617,6 +2621,8 @@
"Map request model names to actual provider model names (JSON format)": "将请求模型名称映射到实际提供商模型名称 (JSON 格式)",
"Map response status codes (JSON format)": "映射响应状态码(JSON 格式)",
"Map upstream status codes to different codes": "将上游状态码映射到不同的代码",
+ "Markers": "标记",
+ "Markers must be a JSON array of objects with start and end strings": "标记必须是包含 start 和 end 字符串的对象 JSON 数组",
"Market Share": "市场份额",
"Marketing": "市场营销",
"Master instances run scheduled background tasks.": "master 实例执行定时后台任务。",