From 0227e81a28287e644480d933a759eebc7b877a03 Mon Sep 17 00:00:00 2001 From: gtxx3600 Date: Thu, 21 May 2026 20:18:04 +0800 Subject: [PATCH 1/4] Fix Claude OpenAI file content conversion Co-authored-by: Codex --- relay/channel/claude/relay-claude.go | 69 ++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 046ccfe681a0..0d9e56a8c731 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -1,10 +1,12 @@ package claude import ( + "encoding/base64" "encoding/json" "fmt" "io" "net/http" + "path/filepath" "strings" "github.com/QuantumNous/new-api/common" @@ -44,6 +46,65 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) { } } +func inferClaudeFileMimeType(fileName string) string { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(fileName)), ".") + if ext == "" { + return "" + } + mimeType := service.GetMimeTypeByExtension(ext) + if mimeType == "application/octet-stream" { + return "" + } + return mimeType +} + +func convertOpenAIFileContentToClaude(c *gin.Context, mediaMessage dto.MediaContent) (*dto.ClaudeMediaMessage, bool, error) { + file := mediaMessage.GetFile() + if file == nil || file.FileData == "" { + return nil, false, nil + } + + source := types.NewFileSourceFromData(file.FileData, inferClaudeFileMimeType(file.FileName)) + base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting file for Claude") + if err != nil { + return nil, false, fmt.Errorf("get file data failed: %s", err.Error()) + } + + if strings.HasPrefix(mimeType, "text/") { + textBytes, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return nil, false, fmt.Errorf("decode text file failed: %s", err.Error()) + } + text := string(textBytes) + if text == "" { + return nil, false, nil + } + return &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](text), + }, true, nil + } + + var contentType string + switch { + case strings.HasPrefix(mimeType, "application/pdf"): + contentType = "document" + case strings.HasPrefix(mimeType, "image/"): + contentType = "image" + default: + return nil, false, nil + } + + return &dto.ClaudeMediaMessage{ + Type: contentType, + Source: &dto.ClaudeMessageSource{ + Type: "base64", + MediaType: mimeType, + Data: base64Data, + }, + }, true, nil +} + func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) { claudeTools := make([]any, 0, len(textRequest.Tools)) @@ -376,6 +437,14 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe Text: common.GetPointer[string](mediaMessage.Text), }) } + case dto.ContentTypeFile: + claudeMediaMessage, ok, err := convertOpenAIFileContentToClaude(c, mediaMessage) + if err != nil { + return nil, err + } + if ok { + claudeMediaMessages = append(claudeMediaMessages, *claudeMediaMessage) + } default: source := mediaMessage.ToFileSource() if source == nil { From 7c721e0fffb4e896fe9d0cc71447dd8f8e80634c Mon Sep 17 00:00:00 2001 From: gtxx3600 Date: Thu, 21 May 2026 20:18:04 +0800 Subject: [PATCH 2/4] Add Claude upstream streaming aggregation Co-authored-by: Codex --- dto/channel_settings.go | 1 + dto/claude.go | 3 + relay/channel/claude/adaptor.go | 22 +- relay/channel/claude/relay-claude.go | 377 ++++++++++++++++++ relay/channel/claude/relay_claude_test.go | 329 +++++++++++++++ relay/claude_handler.go | 9 +- relay/common/relay_info.go | 27 ++ relay/common/relay_info_test.go | 21 + relay/compatible_handler.go | 9 +- relay/helper/stream_scanner.go | 18 +- relay/helper/stream_scanner_test.go | 38 +- .../channels/modals/EditChannelModal.jsx | 62 ++- web/classic/src/i18n/locales/en.json | 3 + web/classic/src/i18n/locales/fr.json | 3 + web/classic/src/i18n/locales/ja.json | 3 + web/classic/src/i18n/locales/ru.json | 3 + web/classic/src/i18n/locales/vi.json | 3 + web/classic/src/i18n/locales/zh-CN.json | 3 + web/classic/src/i18n/locales/zh-TW.json | 3 + web/classic/src/i18n/locales/zh.json | 3 + .../drawers/channel-mutate-drawer.tsx | 45 ++- .../src/features/channels/lib/channel-form.ts | 9 + web/default/src/features/channels/types.ts | 1 + web/default/src/i18n/locales/en.json | 3 + web/default/src/i18n/locales/fr.json | 3 + web/default/src/i18n/locales/ja.json | 3 + web/default/src/i18n/locales/ru.json | 3 + web/default/src/i18n/locales/vi.json | 3 + web/default/src/i18n/locales/zh.json | 3 + 29 files changed, 998 insertions(+), 15 deletions(-) diff --git a/dto/channel_settings.go b/dto/channel_settings.go index b6a1ab9f7138..9989c00b8294 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -5,6 +5,7 @@ type ChannelSettings struct { ThinkingToContent bool `json:"thinking_to_content,omitempty"` Proxy string `json:"proxy"` PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` + ForceUpstreamStream bool `json:"force_upstream_stream,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` SystemPromptOverride bool `json:"system_prompt_override,omitempty"` } diff --git a/dto/claude.go b/dto/claude.go index d7fed412aaa9..c8bc24a66057 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -22,6 +22,7 @@ type ClaudeMediaMessage struct { Source *ClaudeMessageSource `json:"source,omitempty"` Usage *ClaudeUsage `json:"usage,omitempty"` StopReason *string `json:"stop_reason,omitempty"` + StopSequence *string `json:"stop_sequence,omitempty"` PartialJson *string `json:"partial_json,omitempty"` Role string `json:"role,omitempty"` Thinking *string `json:"thinking,omitempty"` @@ -496,6 +497,7 @@ type ClaudeResponse struct { Content []ClaudeMediaMessage `json:"content,omitempty"` Completion string `json:"completion,omitempty"` StopReason string `json:"stop_reason,omitempty"` + StopSequence *string `json:"stop_sequence,omitempty"` Model string `json:"model,omitempty"` Error any `json:"error,omitempty"` Usage *ClaudeUsage `json:"usage,omitempty"` @@ -596,5 +598,6 @@ func (u *ClaudeUsage) GetCacheCreationTotalTokens() int { } type ClaudeServerToolUse struct { + // TODO: Preserve future Anthropic server_tool_use counters when new fields are introduced. WebSearchRequests int `json:"web_search_requests"` } diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 6daf5b6f245e..da5e8141beff 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -7,9 +7,11 @@ import ( "net/http" "net/url" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/relay/channel" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/types" @@ -25,6 +27,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt } func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + enableClaudeUpstreamStreamIfNeeded(info, request) return request, nil } @@ -95,7 +98,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if request == nil { return nil, errors.New("request is nil") } - return RequestOpenAI2ClaudeMessage(c, *request) + claudeReq, err := RequestOpenAI2ClaudeMessage(c, *request) + if err != nil { + return nil, err + } + enableClaudeUpstreamStreamIfNeeded(info, claudeReq) + return claudeReq, nil } func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { @@ -118,6 +126,18 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { info.FinalRequestRelayFormat = types.RelayFormatClaude + if info.UpstreamStream && !info.IsStream { + result, usage, err := AggregateClaudeStreamResponse(c, resp, info) + if err != nil { + return usage, err + } + if resultBody, marshalErr := common.Marshal(result); marshalErr != nil { + return usage, types.NewError(marshalErr, types.ErrorCodeBadResponseBody) + } else { + service.IOCopyBytesGracefully(c, nonStreamJSONResponse(resp), resultBody) + } + return usage, nil + } if info.IsStream { return ClaudeStreamHandler(c, resp, info) } else { diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 0d9e56a8c731..ac88d8c38a76 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -1,12 +1,14 @@ package claude import ( + "bufio" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "path/filepath" + "sort" "strings" "github.com/QuantumNous/new-api/common" @@ -46,6 +48,28 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) { } } +func enableClaudeUpstreamStreamIfNeeded(info *relaycommon.RelayInfo, request *dto.ClaudeRequest) { + if info == nil || request == nil || !info.ShouldUseUpstreamStream() { + return + } + request.Stream = common.GetPointer(true) + info.UpstreamStream = true +} + +func nonStreamJSONResponse(resp *http.Response) *http.Response { + if resp == nil { + return nil + } + cloned := *resp + cloned.Header = resp.Header.Clone() + cloned.Header.Set("Content-Type", "application/json") + cloned.Header.Del("Transfer-Encoding") + cloned.Header.Del("Cache-Control") + cloned.Header.Del("Connection") + cloned.Header.Del("X-Accel-Buffering") + return &cloned +} + func inferClaudeFileMimeType(fileName string) string { ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(fileName)), ".") if ext == "" { @@ -659,6 +683,359 @@ type ClaudeResponseInfo struct { Done bool } +type claudeStreamAggregate struct { + Info *ClaudeResponseInfo + TextBlocks map[int]*strings.Builder + Thinking strings.Builder + ToolCalls map[int]*dto.ToolCallResponse + StopReason string + StopSequence *string + WebSearchRequests int +} + +func newClaudeStreamAggregate(info *relaycommon.RelayInfo) *claudeStreamAggregate { + model := "" + if info != nil { + model = info.UpstreamModelName + } + return &claudeStreamAggregate{ + Info: &ClaudeResponseInfo{ + ResponseId: "", + Created: common.GetTimestamp(), + Model: model, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + }, + TextBlocks: make(map[int]*strings.Builder), + ToolCalls: make(map[int]*dto.ToolCallResponse), + } +} + +func (a *claudeStreamAggregate) apply(c *gin.Context, data string) *types.NewAPIError { + var claudeResponse dto.ClaudeResponse + if err := common.UnmarshalJsonStr(data, &claudeResponse); err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody) + } + if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { + return types.WithClaudeError(*claudeError, http.StatusInternalServerError) + } + if claudeResponse.StopReason != "" { + maybeMarkClaudeRefusal(c, claudeResponse.StopReason) + } + if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil { + maybeMarkClaudeRefusal(c, *claudeResponse.Delta.StopReason) + } + FormatClaudeResponseInfo(&claudeResponse, nil, a.Info) + a.recordServerToolUse(&claudeResponse) + + switch claudeResponse.Type { + case "content_block_start": + a.applyContentBlockStart(&claudeResponse) + case "content_block_delta": + a.applyContentBlockDelta(&claudeResponse) + case "message_delta": + if claudeResponse.Delta != nil { + if claudeResponse.Delta.StopReason != nil { + a.StopReason = *claudeResponse.Delta.StopReason + } + if claudeResponse.Delta.StopSequence != nil { + a.StopSequence = claudeResponse.Delta.StopSequence + } + } + case "message_stop": + a.Info.Done = true + } + return nil +} + +func (a *claudeStreamAggregate) recordServerToolUse(claudeResponse *dto.ClaudeResponse) { + if claudeResponse == nil || claudeResponse.Usage == nil || claudeResponse.Usage.ServerToolUse == nil { + return + } + if claudeResponse.Usage.ServerToolUse.WebSearchRequests > a.WebSearchRequests { + a.WebSearchRequests = claudeResponse.Usage.ServerToolUse.WebSearchRequests + } +} + +func (a *claudeStreamAggregate) applyContentBlockStart(claudeResponse *dto.ClaudeResponse) { + if claudeResponse == nil || claudeResponse.ContentBlock == nil { + return + } + index := claudeResponse.GetIndex() + switch claudeResponse.ContentBlock.Type { + case "text": + if claudeResponse.ContentBlock.Text != nil { + a.textBlock(index).WriteString(*claudeResponse.ContentBlock.Text) + } + case "thinking": + if claudeResponse.ContentBlock.Thinking != nil { + a.Thinking.WriteString(*claudeResponse.ContentBlock.Thinking) + } + case "tool_use": + a.ToolCalls[index] = &dto.ToolCallResponse{ + ID: claudeResponse.ContentBlock.Id, + Type: "function", + Function: dto.FunctionResponse{ + Name: claudeResponse.ContentBlock.Name, + Arguments: "", + }, + } + } +} + +func (a *claudeStreamAggregate) applyContentBlockDelta(claudeResponse *dto.ClaudeResponse) { + if claudeResponse == nil || claudeResponse.Delta == nil { + return + } + index := claudeResponse.GetIndex() + if claudeResponse.Delta.Text != nil { + a.textBlock(index).WriteString(*claudeResponse.Delta.Text) + } + if claudeResponse.Delta.Thinking != nil { + a.Thinking.WriteString(*claudeResponse.Delta.Thinking) + } + if claudeResponse.Delta.PartialJson != nil { + toolCall, ok := a.ToolCalls[index] + if !ok { + toolCall = &dto.ToolCallResponse{ + Type: "function", + } + a.ToolCalls[index] = toolCall + } + toolCall.Function.Arguments += *claudeResponse.Delta.PartialJson + } +} + +func (a *claudeStreamAggregate) textBlock(index int) *strings.Builder { + builder, ok := a.TextBlocks[index] + if !ok { + builder = &strings.Builder{} + a.TextBlocks[index] = builder + } + return builder +} + +func (a *claudeStreamAggregate) text() string { + var out strings.Builder + for _, index := range sortedClaudeBlockIndexes(a.TextBlocks) { + out.WriteString(a.TextBlocks[index].String()) + } + return out.String() +} + +func (a *claudeStreamAggregate) orderedToolCalls() []dto.ToolCallResponse { + if len(a.ToolCalls) == 0 { + return nil + } + toolCalls := make([]dto.ToolCallResponse, 0, len(a.ToolCalls)) + for _, index := range sortedClaudeBlockIndexes(a.ToolCalls) { + toolCalls = append(toolCalls, *a.ToolCalls[index]) + } + return toolCalls +} + +func sortedClaudeBlockIndexes[T any](blocks map[int]T) []int { + indexes := make([]int, 0, len(blocks)) + for index := range blocks { + indexes = append(indexes, index) + } + sort.Ints(indexes) + return indexes +} + +func (a *claudeStreamAggregate) usage(info *relaycommon.RelayInfo, c *gin.Context) *dto.Usage { + if a.Info.Usage == nil { + a.Info.Usage = &dto.Usage{} + } + if a.Info.Usage.CompletionTokens == 0 || !a.Info.Done { + model := a.Info.Model + if model == "" && info != nil { + model = info.UpstreamModelName + } + promptTokens := 0 + if info != nil { + promptTokens = info.GetEstimatePromptTokens() + } + fallback := responseTextUsage(c, a.text()+a.Thinking.String(), model, promptTokens) + if a.Info.Usage.CompletionTokens == 0 { + a.Info.Usage.CompletionTokens = fallback.CompletionTokens + } + if a.Info.Usage.PromptTokens == 0 { + a.Info.Usage.PromptTokens = fallback.PromptTokens + } + } + a.Info.Usage.TotalTokens = a.Info.Usage.PromptTokens + a.Info.Usage.CompletionTokens + a.Info.Usage.UsageSemantic = "anthropic" + return a.Info.Usage +} + +func responseTextUsage(c *gin.Context, responseText string, model string, promptTokens int) *dto.Usage { + if c != nil { + return service.ResponseText2Usage(c, responseText, model, promptTokens) + } + completionTokens := service.EstimateTokenByModel(model, responseText) + return &dto.Usage{ + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: promptTokens + completionTokens, + } +} + +func (a *claudeStreamAggregate) openAIResponse(info *relaycommon.RelayInfo, c *gin.Context) *dto.OpenAITextResponse { + usage := buildOpenAIStyleUsageFromClaudeUsage(a.usage(info, c)) + model := a.Info.Model + if model == "" && info != nil { + model = info.UpstreamModelName + } + finishReason := stopReasonClaude2OpenAI(a.StopReason) + if finishReason == "null" || finishReason == "" { + finishReason = constant.FinishReasonStop + } + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + }, + FinishReason: finishReason, + } + choice.Message.SetStringContent(a.text()) + if thinking := a.Thinking.String(); thinking != "" { + choice.Message.ReasoningContent = &thinking + } + if toolCalls := a.orderedToolCalls(); len(toolCalls) > 0 { + choice.Message.SetToolCalls(toolCalls) + if a.StopReason == "tool_use" { + choice.FinishReason = constant.FinishReasonToolCalls + } + } + return &dto.OpenAITextResponse{ + Id: a.Info.ResponseId, + Model: model, + Object: "chat.completion", + Created: a.Info.Created, + Choices: []dto.OpenAITextResponseChoice{choice}, + Usage: usage, + } +} + +func (a *claudeStreamAggregate) claudeResponse(info *relaycommon.RelayInfo, c *gin.Context) *dto.ClaudeResponse { + usage := a.usage(info, c) + model := a.Info.Model + if model == "" && info != nil { + model = info.UpstreamModelName + } + content := make([]dto.ClaudeMediaMessage, 0) + if thinking := a.Thinking.String(); thinking != "" { + thinkingBlock := dto.ClaudeMediaMessage{Type: "thinking"} + thinkingBlock.Thinking = &thinking + content = append(content, thinkingBlock) + } + if text := a.text(); text != "" { + textBlock := dto.ClaudeMediaMessage{Type: "text"} + textBlock.SetText(text) + content = append(content, textBlock) + } + for _, toolCall := range a.orderedToolCalls() { + var input any = map[string]any{} + if toolCall.Function.Arguments != "" { + var parsed any + if err := common.UnmarshalJsonStr(toolCall.Function.Arguments, &parsed); err == nil { + input = parsed + } else { + input = toolCall.Function.Arguments + } + } + content = append(content, dto.ClaudeMediaMessage{ + Type: "tool_use", + Id: toolCall.ID, + Name: toolCall.Function.Name, + Input: input, + }) + } + claudeUsage := usageToClaudeUsage(usage) + if claudeUsage != nil && a.WebSearchRequests > 0 { + claudeUsage.ServerToolUse = &dto.ClaudeServerToolUse{ + WebSearchRequests: a.WebSearchRequests, + } + } + return &dto.ClaudeResponse{ + Id: a.Info.ResponseId, + Type: "message", + Role: "assistant", + Content: content, + StopReason: a.StopReason, + StopSequence: a.StopSequence, + Model: model, + Usage: claudeUsage, + } +} + +func usageToClaudeUsage(usage *dto.Usage) *dto.ClaudeUsage { + if usage == nil { + return nil + } + claudeUsage := &dto.ClaudeUsage{ + InputTokens: usage.PromptTokens, + OutputTokens: usage.CompletionTokens, + CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens, + CacheCreationInputTokens: usage.PromptTokensDetails.CachedCreationTokens, + } + if usage.ClaudeCacheCreation5mTokens > 0 || usage.ClaudeCacheCreation1hTokens > 0 { + claudeUsage.CacheCreation = &dto.ClaudeCacheCreationUsage{ + Ephemeral5mInputTokens: usage.ClaudeCacheCreation5mTokens, + Ephemeral1hInputTokens: usage.ClaudeCacheCreation1hTokens, + } + } + return claudeUsage +} + +func AggregateClaudeStreamResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (any, *dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + return nil, nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + defer service.CloseResponseBodyGracefully(resp) + + aggregate := newClaudeStreamAggregate(info) + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, helper.InitialScannerBufferSize), helper.DefaultMaxScannerBufferSize) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" { + continue + } + // Anthropic ends Claude streams with message_stop; keep [DONE] support for compatible providers. + if strings.HasPrefix(data, "[DONE]") { + break + } + if info != nil { + info.SetFirstResponseTime() + info.ReceivedResponseCount++ + } + if err := aggregate.apply(c, data); err != nil { + return nil, aggregate.Info.Usage, err + } + } + if err := scanner.Err(); err != nil { + return nil, aggregate.Info.Usage, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + usage := aggregate.usage(info, c) + if c != nil && aggregate.WebSearchRequests > 0 { + c.Set("claude_web_search_requests", aggregate.WebSearchRequests) + } + switch { + case info != nil && info.RelayFormat == types.RelayFormatClaude: + return aggregate.claudeResponse(info, c), usage, nil + default: + return aggregate.openAIResponse(info, c), usage, nil + } +} + func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int { if usage == nil { return 0 diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go index fdc7b38e5ecc..91fa026e7f20 100644 --- a/relay/channel/claude/relay_claude_test.go +++ b/relay/channel/claude/relay_claude_test.go @@ -2,10 +2,18 @@ package claude import ( "encoding/base64" + "io" + "net/http" + "net/http/httptest" "strings" "testing" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -380,3 +388,324 @@ func TestRequestOpenAI2ClaudeMessage_ConvertsTextFileContentToText(t *testing.T) require.NotNil(t, content[0].Text) require.Equal(t, "alpha\nbeta", *content[0].Text) } + +func TestShouldUseUpstreamStreamForNonStreamClaude(t *testing.T) { + info := &relaycommon.RelayInfo{ + IsStream: false, + RelayMode: relayconstant.RelayModeUnknown, + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeAnthropic, + ChannelSetting: dto.ChannelSettings{ + ForceUpstreamStream: true, + }, + }, + } + + require.True(t, info.ShouldUseUpstreamStream()) + + claudeReq := &dto.ClaudeRequest{Model: "claude-3-5-sonnet"} + enableClaudeUpstreamStreamIfNeeded(info, claudeReq) + require.NotNil(t, claudeReq.Stream) + require.True(t, *claudeReq.Stream) +} + +func TestShouldUseUpstreamStreamSkipsDownstreamStream(t *testing.T) { + info := &relaycommon.RelayInfo{ + IsStream: true, + RelayMode: relayconstant.RelayModeUnknown, + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeAnthropic, + ChannelSetting: dto.ChannelSettings{ + ForceUpstreamStream: true, + }, + }, + } + + require.False(t, info.ShouldUseUpstreamStream()) +} + +func TestShouldUseUpstreamStreamSkipsPassThroughBody(t *testing.T) { + info := &relaycommon.RelayInfo{ + IsStream: false, + RelayMode: relayconstant.RelayModeUnknown, + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeAnthropic, + ChannelSetting: dto.ChannelSettings{ + ForceUpstreamStream: true, + PassThroughBodyEnabled: true, + }, + }, + } + + require.False(t, info.ShouldUseUpstreamStream()) +} + +func TestEnsureUpstreamStreamFieldRestoresStreamAfterOverride(t *testing.T) { + info := &relaycommon.RelayInfo{UpstreamStream: true} + + result, err := relaycommon.EnsureUpstreamStreamField([]byte(`{"model":"claude","stream":false}`), info) + require.NoError(t, err) + require.JSONEq(t, `{"model":"claude","stream":true}`, string(result)) +} + +func TestEnsureUpstreamStreamFieldKeepsExistingStreamTrue(t *testing.T) { + info := &relaycommon.RelayInfo{UpstreamStream: true} + + result, err := relaycommon.EnsureUpstreamStreamField([]byte(`{"model":"claude","stream":true}`), info) + require.NoError(t, err) + require.JSONEq(t, `{"model":"claude","stream":true}`, string(result)) +} + +func TestAggregateClaudeStreamWithNilInfoFallsBackToOpenAIResponse(t *testing.T) { + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_nil","model":"claude-3-5-sonnet","usage":{"input_tokens":5}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"Hello"}}`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":5,"output_tokens":1}}`, + `data: [DONE]`, + }, "\n"))), + } + + result, usage, err := AggregateClaudeStreamResponse(nil, resp, nil) + require.Nil(t, err) + require.NotNil(t, usage) + require.Equal(t, 5, usage.PromptTokens) + require.Equal(t, 1, usage.CompletionTokens) + + openAIResp, ok := result.(*dto.OpenAITextResponse) + require.True(t, ok) + require.Equal(t, "msg_nil", openAIResp.Id) + require.Equal(t, "claude-3-5-sonnet", openAIResp.Model) + require.Equal(t, "Hello", openAIResp.Choices[0].Message.StringContent()) +} + +func TestAggregateClaudeStreamToOpenAIResponse(t *testing.T) { + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatOpenAI, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet", + }, + } + info.SetEstimatePromptTokens(7) + + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-5-sonnet","usage":{"input_tokens":10}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"Hello"}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":3}}`, + `data: [DONE]`, + }, "\n"))), + } + + result, usage, err := AggregateClaudeStreamResponse(nil, resp, info) + require.Nil(t, err) + require.NotNil(t, usage) + require.Equal(t, 10, usage.PromptTokens) + require.Equal(t, 3, usage.CompletionTokens) + + openAIResp, ok := result.(*dto.OpenAITextResponse) + require.True(t, ok) + require.Equal(t, "msg_123", openAIResp.Id) + require.Equal(t, "chat.completion", openAIResp.Object) + require.Equal(t, "claude-3-5-sonnet", openAIResp.Model) + require.Len(t, openAIResp.Choices, 1) + require.Equal(t, "assistant", openAIResp.Choices[0].Message.Role) + require.Equal(t, "Hello world", openAIResp.Choices[0].Message.StringContent()) + require.Equal(t, "stop", openAIResp.Choices[0].FinishReason) + require.Equal(t, 13, openAIResp.Usage.TotalTokens) +} + +func TestAggregateClaudeStreamToClaudeResponse(t *testing.T) { + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet", + }, + } + + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_456","model":"claude-3-5-sonnet","usage":{"input_tokens":11}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"Bonjour"}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}}`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":11,"output_tokens":2}}`, + `data: [DONE]`, + }, "\n"))), + } + + result, usage, err := AggregateClaudeStreamResponse(nil, resp, info) + require.Nil(t, err) + require.NotNil(t, usage) + + claudeResp, ok := result.(*dto.ClaudeResponse) + require.True(t, ok) + require.Equal(t, "msg_456", claudeResp.Id) + require.Equal(t, "message", claudeResp.Type) + require.Equal(t, "assistant", claudeResp.Role) + require.Equal(t, "claude-3-5-sonnet", claudeResp.Model) + require.Equal(t, "end_turn", claudeResp.StopReason) + require.Len(t, claudeResp.Content, 1) + require.Equal(t, "text", claudeResp.Content[0].Type) + require.Equal(t, "Bonjour!", claudeResp.Content[0].GetText()) + require.NotNil(t, claudeResp.Usage) + require.Equal(t, 11, claudeResp.Usage.InputTokens) + require.Equal(t, 2, claudeResp.Usage.OutputTokens) +} + +func TestAggregateClaudeStreamToClaudeResponsePreservesCacheUsage(t *testing.T) { + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet", + }, + } + + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_cache","model":"claude-3-5-sonnet","usage":{"input_tokens":11,"cache_read_input_tokens":3,"cache_creation_input_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":5}}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"Cached"}}`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":11,"output_tokens":2,"cache_read_input_tokens":3,"cache_creation_input_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":5}}}`, + `data: [DONE]`, + }, "\n"))), + } + + result, usage, err := AggregateClaudeStreamResponse(nil, resp, info) + require.Nil(t, err) + require.NotNil(t, usage) + require.Equal(t, 3, usage.PromptTokensDetails.CachedTokens) + require.Equal(t, 7, usage.PromptTokensDetails.CachedCreationTokens) + require.Equal(t, 2, usage.ClaudeCacheCreation5mTokens) + require.Equal(t, 5, usage.ClaudeCacheCreation1hTokens) + + claudeResp, ok := result.(*dto.ClaudeResponse) + require.True(t, ok) + require.NotNil(t, claudeResp.Usage) + require.Equal(t, 3, claudeResp.Usage.CacheReadInputTokens) + require.Equal(t, 7, claudeResp.Usage.CacheCreationInputTokens) + require.NotNil(t, claudeResp.Usage.CacheCreation) + require.Equal(t, 2, claudeResp.Usage.CacheCreation.Ephemeral5mInputTokens) + require.Equal(t, 5, claudeResp.Usage.CacheCreation.Ephemeral1hInputTokens) +} + +func TestAggregateClaudeStreamToClaudeResponseJoinsTextBlocksByIndex(t *testing.T) { + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet", + }, + } + + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_blocks","model":"claude-3-5-sonnet","usage":{"input_tokens":8}}}`, + `data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":"second"}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"first "}}`, + `data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" block"}}`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":8,"output_tokens":3}}`, + `data: [DONE]`, + }, "\n"))), + } + + result, _, err := AggregateClaudeStreamResponse(nil, resp, info) + require.Nil(t, err) + + claudeResp, ok := result.(*dto.ClaudeResponse) + require.True(t, ok) + require.Len(t, claudeResp.Content, 1) + require.Equal(t, "first second block", claudeResp.Content[0].GetText()) +} + +func TestAggregateClaudeStreamToClaudeResponsePreservesStopSequence(t *testing.T) { + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet", + }, + } + + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_stop","model":"claude-3-5-sonnet","usage":{"input_tokens":9}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"Done"}}`, + `data: {"type":"message_delta","delta":{"stop_reason":"stop_sequence","stop_sequence":"END"},"usage":{"input_tokens":9,"output_tokens":1}}`, + `data: [DONE]`, + }, "\n"))), + } + + result, _, err := AggregateClaudeStreamResponse(nil, resp, info) + require.Nil(t, err) + + claudeResp, ok := result.(*dto.ClaudeResponse) + require.True(t, ok) + require.Equal(t, "stop_sequence", claudeResp.StopReason) + require.NotNil(t, claudeResp.StopSequence) + require.Equal(t, "END", *claudeResp.StopSequence) +} + +func TestAggregateClaudeStreamToOpenAIResponseWithToolUse(t *testing.T) { + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatOpenAI, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet", + }, + } + + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_tool","model":"claude-3-5-sonnet","usage":{"input_tokens":20}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\""}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":":\"Paris\"}"}}`, + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"input_tokens":20,"output_tokens":4}}`, + `data: [DONE]`, + }, "\n"))), + } + + result, _, err := AggregateClaudeStreamResponse(nil, resp, info) + require.Nil(t, err) + + openAIResp, ok := result.(*dto.OpenAITextResponse) + require.True(t, ok) + require.Equal(t, "tool_calls", openAIResp.Choices[0].FinishReason) + + toolCalls := openAIResp.Choices[0].Message.ParseToolCalls() + require.Len(t, toolCalls, 1) + require.Equal(t, "toolu_1", toolCalls[0].ID) + require.Equal(t, "function", toolCalls[0].Type) + require.Equal(t, "get_weather", toolCalls[0].Function.Name) + require.Equal(t, `{"city":"Paris"}`, toolCalls[0].Function.Arguments) +} + +func TestAggregateClaudeStreamRecordsWebSearchUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet", + }, + } + + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_search","model":"claude-3-5-sonnet","usage":{"input_tokens":15}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"Done"}}`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":15,"output_tokens":2,"server_tool_use":{"web_search_requests":2}}}`, + `data: [DONE]`, + }, "\n"))), + } + + result, _, err := AggregateClaudeStreamResponse(ctx, resp, info) + require.Nil(t, err) + require.Equal(t, 2, ctx.GetInt("claude_web_search_requests")) + + claudeResp, ok := result.(*dto.ClaudeResponse) + require.True(t, ok) + require.NotNil(t, claudeResp.Usage) + require.NotNil(t, claudeResp.Usage.ServerToolUse) + require.Equal(t, 2, claudeResp.Usage.ServerToolUse.WebSearchRequests) +} diff --git a/relay/claude_handler.go b/relay/claude_handler.go index ec028c71f418..816f0693da47 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -178,6 +178,11 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } } + jsonData, err = relaycommon.EnsureUpstreamStreamField(jsonData, info) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + logger.LogDebug(c, "requestBody: %s", jsonData) requestBody = bytes.NewBuffer(jsonData) } @@ -191,7 +196,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if resp != nil { httpResp = resp.(*http.Response) - info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") && !info.UpstreamStream { + info.IsStream = true + } if httpResp.StatusCode != http.StatusOK { newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 64d4d4eedfaa..3bae4af04699 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/tidwall/sjson" ) type ThinkingContentInfo struct { @@ -97,6 +98,7 @@ type RelayInfo struct { isFirstResponse bool //SendLastReasoningResponse bool IsStream bool + UpstreamStream bool IsGeminiBatchEmbedding bool IsPlayground bool UsePrice bool @@ -180,6 +182,22 @@ type RelayInfo struct { *TaskRelayInfo } +func (info *RelayInfo) ShouldUseUpstreamStream() bool { + if info == nil || info.ChannelMeta == nil { + return false + } + if info.IsStream { + return false + } + if !info.ChannelSetting.ForceUpstreamStream { + return false + } + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + return false + } + return info.ChannelType == constant.ChannelTypeAnthropic +} + func (info *RelayInfo) InitChannelMeta(c *gin.Context) { channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType) paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) @@ -244,6 +262,7 @@ func (info *RelayInfo) ToString() string { fmt.Fprintf(b, "RelayFormat: %s, ", info.RelayFormat) fmt.Fprintf(b, "RelayMode: %d, ", info.RelayMode) fmt.Fprintf(b, "IsStream: %t, ", info.IsStream) + fmt.Fprintf(b, "UpstreamStream: %t, ", info.UpstreamStream) fmt.Fprintf(b, "IsPlayground: %t, ", info.IsPlayground) fmt.Fprintf(b, "RequestURLPath: %q, ", info.RequestURLPath) fmt.Fprintf(b, "OriginModelName: %q, ", info.OriginModelName) @@ -851,6 +870,14 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther return jsonDataAfter, nil } +func EnsureUpstreamStreamField(jsonData []byte, info *RelayInfo) ([]byte, error) { + if info == nil || !info.UpstreamStream { + return jsonData, nil + } + + return sjson.SetBytes(jsonData, "stream", true) +} + // RemoveGeminiDisabledFields removes disabled fields from Gemini request JSON data // Currently supports removing functionResponse.id field which Vertex AI does not support func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) { diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go index e53ec804ca06..7a8d5d7930e2 100644 --- a/relay/common/relay_info_test.go +++ b/relay/common/relay_info_test.go @@ -5,6 +5,7 @@ import ( "github.com/QuantumNous/new-api/types" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestRelayInfoGetFinalRequestRelayFormatPrefersExplicitFinal(t *testing.T) { @@ -38,3 +39,23 @@ func TestRelayInfoGetFinalRequestRelayFormatNilReceiver(t *testing.T) { var info *RelayInfo require.Equal(t, types.RelayFormat(""), info.GetFinalRequestRelayFormat()) } + +func TestEnsureUpstreamStreamFieldSetsOnlyStream(t *testing.T) { + input := []byte(`{"model":"claude","large":9007199254740993,"stream":false,"messages":[{"role":"user","content":"hi"}]}`) + + result, err := EnsureUpstreamStreamField(input, &RelayInfo{UpstreamStream: true}) + require.NoError(t, err) + + require.True(t, gjson.GetBytes(result, "stream").Bool()) + require.Equal(t, "9007199254740993", gjson.GetBytes(result, "large").Raw) + require.Contains(t, string(result), `"large":9007199254740993`) + require.Contains(t, string(result), `"messages":[{"role":"user","content":"hi"}]`) +} + +func TestEnsureUpstreamStreamFieldSkipsWhenUpstreamStreamDisabled(t *testing.T) { + input := []byte(`{"model":"claude","stream":false}`) + + result, err := EnsureUpstreamStreamField(input, &RelayInfo{}) + require.NoError(t, err) + require.Equal(t, input, result) +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index fdd54f39d194..099028855c2c 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -174,6 +174,11 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types } } + jsonData, err = relaycommon.EnsureUpstreamStreamField(jsonData, info) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + logger.LogDebug(c, "text request body: %s", jsonData) requestBody = bytes.NewBuffer(jsonData) @@ -189,7 +194,9 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types if resp != nil { httpResp = resp.(*http.Response) - info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") && !info.UpstreamStream { + info.IsStream = true + } if httpResp.StatusCode != http.StatusOK { newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index 1d44b80443cd..1d8e8a1ba70e 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -40,7 +40,6 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon return } - // 无条件新建 StreamStatus info.StreamStatus = relaycommon.NewStreamStatus() // 确保响应体总是被关闭 @@ -50,12 +49,17 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon } }() + var ticker *time.Ticker + var timeoutC <-chan time.Time streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second + if streamingTimeout > 0 { + ticker = time.NewTicker(streamingTimeout) + timeoutC = ticker.C + } var ( stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞 scanner = bufio.NewScanner(resp.Body) - ticker = time.NewTicker(streamingTimeout) pingTicker *time.Ticker writeMutex sync.Mutex // Mutex to protect concurrent writes wg sync.WaitGroup // 用于等待所有 goroutine 退出 @@ -83,7 +87,9 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon // 通知所有 goroutine 停止 common.SafeSendBool(stopChan, true) - ticker.Stop() + if ticker != nil { + ticker.Stop() + } if pingTicker != nil { pingTicker.Stop() } @@ -226,7 +232,9 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon default: } - ticker.Reset(streamingTimeout) + if ticker != nil { + ticker.Reset(streamingTimeout) + } data := scanner.Text() logger.LogDebug(c, "stream scanner data: %s", data) @@ -270,7 +278,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon // 主循环等待完成或超时 select { - case <-ticker.C: + case <-timeoutC: info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonTimeout, nil) case <-stopChan: // EndReason already set by the goroutine that triggered stopChan diff --git a/relay/helper/stream_scanner_test.go b/relay/helper/stream_scanner_test.go index 9d6f3bb49123..139898c26f9c 100644 --- a/relay/helper/stream_scanner_test.go +++ b/relay/helper/stream_scanner_test.go @@ -626,7 +626,43 @@ func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) { StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {}) assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason) - assert.Equal(t, 1, info.StreamStatus.TotalErrorCount()) + assert.Equal(t, 0, info.StreamStatus.TotalErrorCount()) +} + +func TestStreamScannerHandler_StreamingTimeoutZeroDisablesTimeout(t *testing.T) { + oldTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 0 + t.Cleanup(func() { constant.StreamingTimeout = oldTimeout }) + + pr, pw := io.Pipe() + go func() { + defer pw.Close() + fmt.Fprint(pw, "data: {\"id\":1}\n") + time.Sleep(200 * time.Millisecond) + fmt.Fprint(pw, "data: [DONE]\n") + }() + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + resp := &http.Response{Body: pr} + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} + + done := make(chan struct{}) + go func() { + StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {}) + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for stream with disabled timeout") + } + + require.NotNil(t, info.StreamStatus) + assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason) } func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) { diff --git a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx index fad105b1c223..c4ef85c8e555 100644 --- a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx @@ -193,6 +193,7 @@ const EditChannelModal = (props) => { thinking_to_content: false, proxy: '', pass_through_body_enabled: false, + force_upstream_stream: false, system_prompt: '', system_prompt_override: false, settings: '', @@ -516,6 +517,7 @@ const EditChannelModal = (props) => { thinking_to_content: false, proxy: '', pass_through_body_enabled: false, + force_upstream_stream: false, system_prompt: '', }); const showApiConfigCard = true; // 控制是否显示 API 配置卡片 @@ -523,20 +525,33 @@ const EditChannelModal = (props) => { // 处理渠道额外设置的更新 const handleChannelSettingsChange = (key, value) => { + const nextSettings = { ...channelSettings, [key]: value }; + if (key === 'pass_through_body_enabled' && value) { + nextSettings.force_upstream_stream = false; + } + if ( + key === 'force_upstream_stream' && + nextSettings.pass_through_body_enabled + ) { + nextSettings.force_upstream_stream = false; + } + // 更新内部状态 - setChannelSettings((prev) => ({ ...prev, [key]: value })); + setChannelSettings(nextSettings); // 同步更新到表单字段 if (formApiRef.current) { - formApiRef.current.setValue(key, value); + formApiRef.current.setValue(key, nextSettings[key]); + if (key === 'pass_through_body_enabled' && value) { + formApiRef.current.setValue('force_upstream_stream', false); + } } // 同步更新inputs状态 - setInputs((prev) => ({ ...prev, [key]: value })); + setInputs((prev) => ({ ...prev, ...nextSettings })); // 生成setting JSON并更新 - const newSettings = { ...channelSettings, [key]: value }; - const settingsJson = JSON.stringify(newSettings); + const settingsJson = JSON.stringify(nextSettings); handleInputChange('setting', settingsJson); }; @@ -867,6 +882,11 @@ const EditChannelModal = (props) => { data.proxy = parsedSettings.proxy || ''; data.pass_through_body_enabled = parsedSettings.pass_through_body_enabled || false; + data.force_upstream_stream = + parsedSettings.force_upstream_stream || false; + if (data.pass_through_body_enabled) { + data.force_upstream_stream = false; + } data.system_prompt = parsedSettings.system_prompt || ''; data.system_prompt_override = parsedSettings.system_prompt_override || false; @@ -876,6 +896,7 @@ const EditChannelModal = (props) => { data.thinking_to_content = false; data.proxy = ''; data.pass_through_body_enabled = false; + data.force_upstream_stream = false; data.system_prompt = ''; data.system_prompt_override = false; } @@ -884,6 +905,7 @@ const EditChannelModal = (props) => { data.thinking_to_content = false; data.proxy = ''; data.pass_through_body_enabled = false; + data.force_upstream_stream = false; data.system_prompt = ''; data.system_prompt_override = false; } @@ -993,6 +1015,7 @@ const EditChannelModal = (props) => { thinking_to_content: data.thinking_to_content, proxy: data.proxy, pass_through_body_enabled: data.pass_through_body_enabled, + force_upstream_stream: data.force_upstream_stream, system_prompt: data.system_prompt, system_prompt_override: data.system_prompt_override || false, }); @@ -1035,6 +1058,7 @@ const EditChannelModal = (props) => { (data.system_prompt && data.system_prompt.trim()) || data.thinking_to_content || data.pass_through_body_enabled || + data.force_upstream_stream || data.force_format || data.claude_beta_query || data.system_prompt_override; @@ -1382,6 +1406,7 @@ const EditChannelModal = (props) => { thinking_to_content: false, proxy: '', pass_through_body_enabled: false, + force_upstream_stream: false, system_prompt: '', system_prompt_override: false, }); @@ -1752,6 +1777,9 @@ const EditChannelModal = (props) => { thinking_to_content: localInputs.thinking_to_content || false, proxy: localInputs.proxy || '', pass_through_body_enabled: localInputs.pass_through_body_enabled || false, + force_upstream_stream: localInputs.pass_through_body_enabled + ? false + : localInputs.force_upstream_stream || false, system_prompt: localInputs.system_prompt || '', system_prompt_override: localInputs.system_prompt_override || false, }; @@ -1833,6 +1861,7 @@ const EditChannelModal = (props) => { delete localInputs.thinking_to_content; delete localInputs.proxy; delete localInputs.pass_through_body_enabled; + delete localInputs.force_upstream_stream; delete localInputs.system_prompt; delete localInputs.system_prompt_override; delete localInputs.is_enterprise_account; @@ -2519,6 +2548,29 @@ const EditChannelModal = (props) => { handleChannelOtherSettingsChange('claude_beta_query', value)} extraText={t('开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)')} /> )} + {inputs.type === 14 && ( + + handleChannelSettingsChange( + 'force_upstream_stream', + value, + ) + } + extraText={ + inputs.pass_through_body_enabled + ? t('请求体透传开启时,此开关会失效') + : t( + '当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信', + ) + } + /> + )} + {inputs.type === 1 && ( handleChannelSettingsChange('force_format', value)} extraText={t('强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)')} /> )} diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index ea6bca1b4c7e..4dfa2279bb46 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -1036,6 +1036,9 @@ "启用签到功能": "Enable check-in feature", "启用绘图功能": "Enable drawing function", "启用请求体透传功能": "Enable request body pass-through functionality", + "强制上游流式": "Force upstream streaming", + "请求体透传开启时,此开关会失效": "This switch is disabled while request body pass-through is enabled", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "Use streaming between this Claude channel and upstream when downstream requests are non-streaming", "启用请求透传": "Enable request pass-through", "启用违规扣费": "Enable violation deduction", "启用额度消费日志记录": "Enable quota consumption logging", diff --git a/web/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json index a24d32bad00c..b244fc2b6bbb 100644 --- a/web/classic/src/i18n/locales/fr.json +++ b/web/classic/src/i18n/locales/fr.json @@ -1034,6 +1034,9 @@ "启用签到功能": "Activer la fonction d'enregistrement", "启用绘图功能": "Activer la fonction de dessin", "启用请求体透传功能": "Activer la fonctionnalité de transmission du corps de la requête", + "强制上游流式": "Forcer le streaming en amont", + "请求体透传开启时,此开关会失效": "Ce commutateur est désactivé lorsque la transmission du corps de la requête est activée", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "Utiliser le streaming entre ce canal Claude et l'amont lorsque les requêtes en aval ne sont pas en streaming", "启用请求透传": "Activer la transmission de la requête", "启用违规扣费": "Activer la déduction de violation", "启用额度消费日志记录": "Activer la journalisation de la consommation de quota", diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json index dde2a1a578e2..fb85e6440113 100644 --- a/web/classic/src/i18n/locales/ja.json +++ b/web/classic/src/i18n/locales/ja.json @@ -1021,6 +1021,9 @@ "启用签到功能": "チェックイン機能を有効にする", "启用绘图功能": "画像生成機能を有効にする", "启用请求体透传功能": "リクエストボディのパススルー機能を有効にします。", + "强制上游流式": "上流ストリーミングを強制", + "请求体透传开启时,此开关会失效": "リクエストボディのパススルーが有効な間、このスイッチは無効になります", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "下流リクエストが非ストリーミングの場合、この Claude チャネルと上流の間でストリーミング通信を使用します", "启用请求透传": "リクエストパススルーを有効にする", "启用违规扣费": "違反課金を有効にする", "启用额度消费日志记录": "クォータ消費のログ記録を有効にする", diff --git a/web/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json index b934dfe1bc5c..100e0ce4a647 100644 --- a/web/classic/src/i18n/locales/ru.json +++ b/web/classic/src/i18n/locales/ru.json @@ -1042,6 +1042,9 @@ "启用签到功能": "Включить функцию регистрации", "启用绘图功能": "Включить функцию рисования", "启用请求体透传功能": "Включить функцию прозрачной передачи тела запроса", + "强制上游流式": "Принудительная потоковая передача вверх", + "请求体透传开启时,此开关会失效": "Этот переключатель отключается, когда включена прозрачная передача тела запроса", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "Использовать потоковую передачу между этим каналом Claude и апстримом, когда нижестоящие запросы не являются потоковыми", "启用请求透传": "Включить прозрачную передачу запросов", "启用违规扣费": "Включить удержание за нарушения", "启用额度消费日志记录": "Включить журналирование потребления квоты", diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json index 771a25fcf201..721405477be6 100644 --- a/web/classic/src/i18n/locales/vi.json +++ b/web/classic/src/i18n/locales/vi.json @@ -1022,6 +1022,9 @@ "启用签到功能": "Bật tính năng đăng nhập", "启用绘图功能": "Bật chức năng vẽ", "启用请求体透传功能": "Bật chức năng truyền qua thân yêu cầu", + "强制上游流式": "Bắt buộc truyền phát lên thượng nguồn", + "请求体透传开启时,此开关会失效": "Công tắc này bị tắt khi bật truyền qua thân yêu cầu", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "Khi yêu cầu phía dưới không truyền phát, dùng truyền phát giữa kênh Claude này và thượng nguồn", "启用请求透传": "Bật truyền qua yêu cầu", "启用违规扣费": "Bật trừ phí vi phạm", "启用额度消费日志记录": "Bật ghi nhật ký tiêu thụ hạn ngạch", diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json index e1141b0414f7..ee4f23231a30 100644 --- a/web/classic/src/i18n/locales/zh-CN.json +++ b/web/classic/src/i18n/locales/zh-CN.json @@ -1011,6 +1011,9 @@ "启用签到功能": "启用签到功能", "启用绘图功能": "启用绘图功能", "启用请求体透传功能": "启用请求体透传功能", + "强制上游流式": "强制上游流式", + "请求体透传开启时,此开关会失效": "请求体透传开启时,此开关会失效", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信", "启用请求透传": "启用请求透传", "启用违规扣费": "启用违规扣费", "启用额度消费日志记录": "启用额度消费日志记录", diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json index 3be48fb4dce5..52698bfde59c 100644 --- a/web/classic/src/i18n/locales/zh-TW.json +++ b/web/classic/src/i18n/locales/zh-TW.json @@ -1019,6 +1019,9 @@ "启用签到功能": "啟用簽到功能", "启用绘图功能": "啟用繪圖功能", "启用请求体透传功能": "啟用請求體透傳功能", + "强制上游流式": "強制上游串流", + "请求体透传开启时,此开关会失效": "請求體透傳開啟時,此開關會失效", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "當下游請求為非串流時,此 Claude 渠道與上游之間使用串流通訊", "启用请求透传": "啟用請求透傳", "启用违规扣费": "啟用違規扣費", "启用额度消费日志记录": "啟用額度消費日誌記錄", diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json index 88ac70c139f9..cf97de807db3 100644 --- a/web/classic/src/i18n/locales/zh.json +++ b/web/classic/src/i18n/locales/zh.json @@ -688,6 +688,9 @@ "启用用户模型请求速率限制(可能会影响高并发性能)": "启用用户模型请求速率限制(可能会影响高并发性能)", "启用绘图功能": "启用绘图功能", "启用请求体透传功能": "启用请求体透传功能", + "强制上游流式": "强制上游流式", + "请求体透传开启时,此开关会失效": "请求体透传开启时,此开关会失效", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信", "启用请求透传": "启用请求透传", "启用额度消费日志记录": "启用额度消费日志记录", "启用验证": "启用验证", diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 39a6e1527b55..87df88e1de02 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -238,6 +238,7 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { values.force_format || values.thinking_to_content || values.pass_through_body_enabled || + values.force_upstream_stream || values.system_prompt_override || values.claude_beta_query || values.upstream_model_update_check_enabled || @@ -395,6 +396,7 @@ export function ChannelMutateDrawer({ const currentModels = form.watch('models') const currentModelMapping = form.watch('model_mapping') const awsKeyType = form.watch('aws_key_type') + const passThroughBodyEnabled = form.watch('pass_through_body_enabled') const upstreamModelUpdateCheckEnabled = form.watch( 'upstream_model_update_check_enabled' ) @@ -417,6 +419,15 @@ export function ChannelMutateDrawer({ } }, [open, resetDoubaoApiUnlock]) + useEffect(() => { + if (passThroughBodyEnabled && form.getValues('force_upstream_stream')) { + form.setValue('force_upstream_stream', false, { + shouldDirty: true, + shouldTouch: true, + }) + } + }, [form, passThroughBodyEnabled]) + // Helper computed values const isBatchMode = multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single' @@ -3146,6 +3157,36 @@ export function ChannelMutateDrawer({ )} /> + + {currentType === 14 && ( + ( + +
+ + {t('Force Upstream Streaming')} + + + {t( + passThroughBodyEnabled + ? 'Disabled while request body pass-through is enabled' + : 'Use streaming between this Claude channel and upstream for non-streaming downstream requests' + )} + +
+ + + +
+ )} + /> + )} diff --git a/web/default/src/features/channels/lib/channel-form.ts b/web/default/src/features/channels/lib/channel-form.ts index 03db2f2355f3..6003a3059bdc 100644 --- a/web/default/src/features/channels/lib/channel-form.ts +++ b/web/default/src/features/channels/lib/channel-form.ts @@ -59,6 +59,7 @@ export const channelFormSchema = z.object({ thinking_to_content: z.boolean().optional(), proxy: z.string().optional(), pass_through_body_enabled: z.boolean().optional(), + force_upstream_stream: z.boolean().optional(), system_prompt: z.string().optional(), system_prompt_override: z.boolean().optional(), // Type-specific settings (stored in settings JSON) @@ -117,6 +118,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = { thinking_to_content: false, proxy: '', pass_through_body_enabled: false, + force_upstream_stream: false, system_prompt: '', system_prompt_override: false, // Type-specific settings @@ -153,6 +155,7 @@ export function transformChannelToFormDefaults( thinking_to_content: false, proxy: '', pass_through_body_enabled: false, + force_upstream_stream: false, system_prompt: '', system_prompt_override: false, } @@ -165,6 +168,9 @@ export function transformChannelToFormDefaults( thinking_to_content: parsed.thinking_to_content || false, proxy: parsed.proxy || '', pass_through_body_enabled: parsed.pass_through_body_enabled || false, + force_upstream_stream: parsed.pass_through_body_enabled + ? false + : parsed.force_upstream_stream || false, system_prompt: parsed.system_prompt || '', system_prompt_override: parsed.system_prompt_override || false, } @@ -274,6 +280,9 @@ function buildSettingJSON(formData: ChannelFormValues): string { thinking_to_content: formData.thinking_to_content || false, proxy: formData.proxy || '', pass_through_body_enabled: formData.pass_through_body_enabled || false, + force_upstream_stream: formData.pass_through_body_enabled + ? false + : formData.force_upstream_stream || false, system_prompt: formData.system_prompt || '', system_prompt_override: formData.system_prompt_override || false, } diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index a282053a3a95..409504e598e0 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -84,6 +84,7 @@ export interface ChannelSettings { thinking_to_content?: boolean proxy?: string pass_through_body_enabled?: boolean + force_upstream_stream?: boolean system_prompt?: string system_prompt_override?: boolean } diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 1560b01992de..b8792523a59b 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -1206,6 +1206,7 @@ "Disable Two-Factor Authentication": "Disable Two-Factor Authentication", "disabled": "disabled", "Disabled": "Disabled", + "Disabled while request body pass-through is enabled": "Disabled while request body pass-through is enabled", "Disabled all channels with tag: {{tag}}": "Disabled all channels with tag: {{tag}}", "Disabled lanes are omitted on save.": "Disabled lanes are omitted on save.", "Disabled Reason": "Disabled Reason", @@ -1787,6 +1788,7 @@ "Force format response to OpenAI standard (OpenAI channel only)": "Force format response to OpenAI standard (OpenAI channel only)", "Force JSON object or schema-conforming output": "Force JSON object or schema-conforming output", "Force SMTP authentication using AUTH LOGIN method": "Force SMTP authentication using AUTH LOGIN method", + "Force Upstream Streaming": "Force Upstream Streaming", "Forest Whisper": "Forest Whisper", "Forgot password": "Forgot password", "Forgot password?": "Forgot password?", @@ -4227,6 +4229,7 @@ "Use Passkey to sign in without entering your password.": "Use Passkey to sign in without entering your password.", "Use secure connection when sending emails": "Use secure connection when sending emails", "Use sidebar shortcut": "Use sidebar shortcut", + "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "Use streaming between this Claude channel and upstream for non-streaming downstream requests", "Use the full-width table to scan prices, then select a row to edit it here.": "Use the full-width table to scan prices, then select a row to edit it here.", "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.", "Use this token for API authentication": "Use this token for API authentication", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 8b555700a150..a47fc035f508 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -1206,6 +1206,7 @@ "Disable Two-Factor Authentication": "Désactiver l'authentification à deux facteurs", "disabled": "désactivé", "Disabled": "Désactivé", + "Disabled while request body pass-through is enabled": "Désactivé lorsque la transmission du corps de la requête est activée", "Disabled all channels with tag: {{tag}}": "Tous les canaux avec le tag {{tag}} ont été désactivés", "Disabled lanes are omitted on save.": "Les voies désactivées sont omises à l’enregistrement.", "Disabled Reason": "Raison de la désactivation", @@ -1787,6 +1788,7 @@ "Force format response to OpenAI standard (OpenAI channel only)": "Forcer la réponse au format standard OpenAI (canal OpenAI uniquement)", "Force JSON object or schema-conforming output": "Forcer une sortie JSON ou conforme à un schéma", "Force SMTP authentication using AUTH LOGIN method": "Forcer l'authentification SMTP en utilisant la méthode AUTH LOGIN", + "Force Upstream Streaming": "Forcer le streaming en amont", "Forest Whisper": "Murmure forestier", "Forgot password": "Mot de passe oublié", "Forgot password?": "Mot de passe oublié ?", @@ -4227,6 +4229,7 @@ "Use Passkey to sign in without entering your password.": "Utilisez une clé d'accès (Passkey) pour vous connecter sans saisir votre mot de passe.", "Use secure connection when sending emails": "Utiliser une connexion sécurisée lors de l'envoi d'e-mails", "Use sidebar shortcut": "Utiliser le raccourci de la barre latérale", + "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "Utiliser le streaming entre ce canal Claude et l'amont pour les requêtes aval non streaming", "Use the full-width table to scan prices, then select a row to edit it here.": "Parcourez les prix dans le tableau, puis sélectionnez une ligne pour la modifier ici.", "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "Utilisez le tableau des groupes tarifaires pour gérer le ratio et l’apparition du groupe dans la liste de création de jeton.", "Use this token for API authentication": "Utilisez ce jeton pour l'authentification API", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 778867e9428e..29703d3a01da 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1206,6 +1206,7 @@ "Disable Two-Factor Authentication": "二要素認証を無効にする", "disabled": "無効", "Disabled": "無効", + "Disabled while request body pass-through is enabled": "リクエストボディのパススルーが有効な間は無効です", "Disabled all channels with tag: {{tag}}": "タグ「{{tag}}」の全チャネルを無効にしました", "Disabled lanes are omitted on save.": "無効な価格レーンは保存時に省略されます。", "Disabled Reason": "無効化の理由", @@ -1787,6 +1788,7 @@ "Force format response to OpenAI standard (OpenAI channel only)": "応答をOpenAI標準に強制フォーマット (OpenAIチャンネルのみ)", "Force JSON object or schema-conforming output": "JSON オブジェクトまたはスキーマ準拠の出力を強制します", "Force SMTP authentication using AUTH LOGIN method": "AUTH LOGIN方式を使用してSMTP認証を強制する", + "Force Upstream Streaming": "上流ストリーミングを強制", "Forest Whisper": "フォレストウィスパー", "Forgot password": "パスワードを忘れた場合", "Forgot password?": "パスワードをお忘れですか?", @@ -4227,6 +4229,7 @@ "Use Passkey to sign in without entering your password.": "パスワードを入力せずにサインインするには、パスキーを使用してください。", "Use secure connection when sending emails": "メール送信時に安全な接続を使用する", "Use sidebar shortcut": "サイドバーのショートカットを使用", + "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "下流が非ストリーミング要求の場合、この Claude チャンネルと上流の間でストリーミングを使用します", "Use the full-width table to scan prices, then select a row to edit it here.": "表で価格を確認し、行を選択してここで編集します。", "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "料金グループ表で倍率と、トークン作成ドロップダウンに表示するかどうかを管理します。", "Use this token for API authentication": "API認証にはこのトークンを使用してください", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 88ae65b07189..282b14d9293d 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -1206,6 +1206,7 @@ "Disable Two-Factor Authentication": "Отключить двухфакторную аутентификацию", "disabled": "отключено", "Disabled": "Отключено", + "Disabled while request body pass-through is enabled": "Отключено, пока включена прозрачная передача тела запроса", "Disabled all channels with tag: {{tag}}": "Все каналы с тегом {{tag}} отключены", "Disabled lanes are omitted on save.": "Отключённые каналы цен не сохраняются.", "Disabled Reason": "Причина отключения", @@ -1787,6 +1788,7 @@ "Force format response to OpenAI standard (OpenAI channel only)": "Принудительно форматировать ответ в соответствии со стандартом OpenAI (только для канала OpenAI)", "Force JSON object or schema-conforming output": "Принудительно вернуть JSON или соответствующий схеме вывод", "Force SMTP authentication using AUTH LOGIN method": "Принудительная аутентификация SMTP с использованием метода AUTH LOGIN", + "Force Upstream Streaming": "Принудительная потоковая передача вверх", "Forest Whisper": "Лесной шёпот", "Forgot password": "Забыли пароль", "Forgot password?": "Забыли пароль?", @@ -4227,6 +4229,7 @@ "Use Passkey to sign in without entering your password.": "Используйте ключ доступа для входа без ввода пароля.", "Use secure connection when sending emails": "Использовать безопасное соединение при отправке электронных писем", "Use sidebar shortcut": "Использовать ярлык боковой панели", + "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "Использовать потоковую передачу между этим каналом Claude и апстримом для непотоковых запросов клиента", "Use the full-width table to scan prices, then select a row to edit it here.": "Просмотрите цены в таблице, затем выберите строку для редактирования здесь.", "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "Используйте таблицу групп тарификации, чтобы управлять коэффициентом и отображением группы в списке создания токена.", "Use this token for API authentication": "Используйте этот токен для аутентификации API", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 5e2e32001b2f..acaa61cbee1a 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -1206,6 +1206,7 @@ "Disable Two-Factor Authentication": "Vô hiệu hóa Xác thực hai yếu tố", "disabled": "vô hiệu hóa", "Disabled": "Đã tắt", + "Disabled while request body pass-through is enabled": "Bị tắt khi bật truyền qua thân yêu cầu", "Disabled all channels with tag: {{tag}}": "Đã tắt tất cả kênh với nhãn: {{tag}}", "Disabled lanes are omitted on save.": "Các kênh bị tắt sẽ được bỏ qua khi lưu.", "Disabled Reason": "Lý do vô hiệu hóa", @@ -1787,6 +1788,7 @@ "Force format response to OpenAI standard (OpenAI channel only)": "Buộc định dạng phản hồi theo tiêu chuẩn OpenAI (chỉ kênh OpenAI)", "Force JSON object or schema-conforming output": "Bắt buộc xuất JSON hoặc theo schema", "Force SMTP authentication using AUTH LOGIN method": "Bắt buộc xác thực SMTP sử dụng phương thức AUTH LOGIN", + "Force Upstream Streaming": "Bắt buộc truyền phát lên thượng nguồn", "Forest Whisper": "Tiếng thì thầm rừng cây", "Forgot password": "Quên mật khẩu", "Forgot password?": "Quên mật khẩu?", @@ -4227,6 +4229,7 @@ "Use Passkey to sign in without entering your password.": "Sử dụng Khóa truy cập để đăng nhập mà không cần nhập mật khẩu của bạn.", "Use secure connection when sending emails": "Sử dụng kết nối an toàn khi gửi email", "Use sidebar shortcut": "Sử dụng phím tắt thanh bên", + "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "Dùng truyền phát giữa kênh Claude này và thượng nguồn cho các yêu cầu phía dưới không truyền phát", "Use the full-width table to scan prices, then select a row to edit it here.": "Duyệt giá trong bảng, rồi chọn một hàng để chỉnh sửa tại đây.", "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "Dùng bảng nhóm định giá để quản lý tỷ lệ và việc nhóm có xuất hiện trong danh sách tạo token hay không.", "Use this token for API authentication": "Sử dụng token này để xác thực API", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 6da35355a926..39778d678c11 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -1206,6 +1206,7 @@ "Disable Two-Factor Authentication": "禁用双重身份验证", "disabled": "已禁用", "Disabled": "已禁用", + "Disabled while request body pass-through is enabled": "请求体透传开启时,此开关会失效", "Disabled all channels with tag: {{tag}}": "已禁用标签「{{tag}}」下的所有渠道", "Disabled lanes are omitted on save.": "关闭的价格通道保存时会被省略。", "Disabled Reason": "禁用原因", @@ -1787,6 +1788,7 @@ "Force format response to OpenAI standard (OpenAI channel only)": "强制将响应格式化为 OpenAI 标准(仅限 OpenAI 渠道)", "Force JSON object or schema-conforming output": "强制输出 JSON 对象或符合 Schema 的结果", "Force SMTP authentication using AUTH LOGIN method": "强制使用 AUTH LOGIN 方法进行 SMTP 认证", + "Force Upstream Streaming": "强制上游流式", "Forest Whisper": "森林低语", "Forgot password": "忘记密码", "Forgot password?": "忘记密码?", @@ -4227,6 +4229,7 @@ "Use Passkey to sign in without entering your password.": "使用通行密钥登录,无需输入密码。", "Use secure connection when sending emails": "发送电子邮件时使用安全连接", "Use sidebar shortcut": "使用侧边栏快捷方式", + "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信", "Use the full-width table to scan prices, then select a row to edit it here.": "先在表格中快速浏览价格,然后选择一行在这里编辑。", "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "使用定价分组表管理倍率,以及该分组是否出现在创建令牌的下拉框中。", "Use this token for API authentication": "使用此令牌进行 API 身份验证", From 3ea8548d74ef185a356042c881771152a617e3f7 Mon Sep 17 00:00:00 2001 From: gtxx3600 Date: Thu, 21 May 2026 20:28:43 +0800 Subject: [PATCH 3/4] Document Claude upstream stream setting Co-authored-by: Codex --- docs/channel/other_setting.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/channel/other_setting.md b/docs/channel/other_setting.md index 43341660b886..e68cdb795ef4 100644 --- a/docs/channel/other_setting.md +++ b/docs/channel/other_setting.md @@ -1,6 +1,6 @@ -# 渠道而外设置说明 +# 渠道额外设置说明 -该配置用于设置一些额外的渠道参数,可以通过 JSON 对象进行配置。主要包含以下两个设置项: +该配置用于设置一些额外的渠道参数,可以通过 JSON 对象进行配置。主要包含以下设置项: 1. force_format - 用于标识是否对数据进行强制格式化为 OpenAI 格式 @@ -14,20 +14,33 @@ - 用于标识是否将思考内容`reasoning_content`转换为``标签拼接到内容中返回 - 类型为布尔值,设置为 true 时启用思考内容转换 +4. pass_through_body_enabled + - 用于标识是否启用请求体透传 + - 类型为布尔值,设置为 true 时将尽量保持客户端请求体原样转发给上游 + - 启用后,部分需要改写上游请求体的渠道功能会失效 + +5. force_upstream_stream + - 用于标识是否在下游非流式请求时,强制 new-api 与上游之间使用流式通信,再由 new-api 聚合为非流式响应返回给下游 + - 类型为布尔值,目前仅适用于支持该能力的 Claude 渠道 + - 适用于上游域名经过 CDN 或负载均衡时,避免非流式长请求因长时间无响应数据被中间层断开 + - 如果启用了 `pass_through_body_enabled`,该设置不会生效;前端会自动关闭并禁用该开关 + -------------------------------------------------------------- ## JSON 格式示例 -以下是一个示例配置,启用强制格式化并设置了代理地址: +以下是一个示例配置,启用强制格式化、思考内容转换、Claude 上游流式通信,并设置了代理地址: ```json { - "force_format": true, - "thinking_to_content": true, - "proxy": "socks5://xxxxxxx" + "force_format": true, + "thinking_to_content": true, + "pass_through_body_enabled": false, + "force_upstream_stream": true, + "proxy": "socks5://xxxxxxx" } ``` -------------------------------------------------------------- -通过调整上述 JSON 配置中的值,可以灵活控制渠道的额外行为,比如是否进行格式化以及使用特定的网络代理。 +通过调整上述 JSON 配置中的值,可以灵活控制渠道的额外行为,比如是否进行格式化、是否使用特定的网络代理,以及是否在支持的渠道上使用上游流式通信。 From d6859561f088bbf6b7e35021a5bb3adcfd3fa80f Mon Sep 17 00:00:00 2001 From: gtxx3600 Date: Thu, 21 May 2026 21:32:01 +0800 Subject: [PATCH 4/4] Refine upstream stream translations Co-authored-by: Codex --- web/classic/src/i18n/locales/ja.json | 4 ++-- web/classic/src/i18n/locales/vi.json | 2 +- web/default/src/i18n/locales/ja.json | 4 ++-- web/default/src/i18n/locales/vi.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json index fb85e6440113..67f2bd3aa80e 100644 --- a/web/classic/src/i18n/locales/ja.json +++ b/web/classic/src/i18n/locales/ja.json @@ -1021,9 +1021,9 @@ "启用签到功能": "チェックイン機能を有効にする", "启用绘图功能": "画像生成機能を有効にする", "启用请求体透传功能": "リクエストボディのパススルー機能を有効にします。", - "强制上游流式": "上流ストリーミングを強制", + "强制上游流式": "アップストリームストリーミングを強制", "请求体透传开启时,此开关会失效": "リクエストボディのパススルーが有効な間、このスイッチは無効になります", - "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "下流リクエストが非ストリーミングの場合、この Claude チャネルと上流の間でストリーミング通信を使用します", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "ダウンストリームリクエストが非ストリーミングの場合、この Claude チャネルとアップストリームの間でストリーミング通信を使用します", "启用请求透传": "リクエストパススルーを有効にする", "启用违规扣费": "違反課金を有効にする", "启用额度消费日志记录": "クォータ消費のログ記録を有効にする", diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json index 721405477be6..b85c80e6f722 100644 --- a/web/classic/src/i18n/locales/vi.json +++ b/web/classic/src/i18n/locales/vi.json @@ -1024,7 +1024,7 @@ "启用请求体透传功能": "Bật chức năng truyền qua thân yêu cầu", "强制上游流式": "Bắt buộc truyền phát lên thượng nguồn", "请求体透传开启时,此开关会失效": "Công tắc này bị tắt khi bật truyền qua thân yêu cầu", - "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "Khi yêu cầu phía dưới không truyền phát, dùng truyền phát giữa kênh Claude này và thượng nguồn", + "当下游请求为非流式时,此 Claude 渠道与上游之间使用流式通信": "Khi yêu cầu hạ nguồn không truyền phát, dùng truyền phát giữa kênh Claude này và thượng nguồn", "启用请求透传": "Bật truyền qua yêu cầu", "启用违规扣费": "Bật trừ phí vi phạm", "启用额度消费日志记录": "Bật ghi nhật ký tiêu thụ hạn ngạch", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 29703d3a01da..6c7d87e524d5 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1788,7 +1788,7 @@ "Force format response to OpenAI standard (OpenAI channel only)": "応答をOpenAI標準に強制フォーマット (OpenAIチャンネルのみ)", "Force JSON object or schema-conforming output": "JSON オブジェクトまたはスキーマ準拠の出力を強制します", "Force SMTP authentication using AUTH LOGIN method": "AUTH LOGIN方式を使用してSMTP認証を強制する", - "Force Upstream Streaming": "上流ストリーミングを強制", + "Force Upstream Streaming": "アップストリームストリーミングを強制", "Forest Whisper": "フォレストウィスパー", "Forgot password": "パスワードを忘れた場合", "Forgot password?": "パスワードをお忘れですか?", @@ -4229,7 +4229,7 @@ "Use Passkey to sign in without entering your password.": "パスワードを入力せずにサインインするには、パスキーを使用してください。", "Use secure connection when sending emails": "メール送信時に安全な接続を使用する", "Use sidebar shortcut": "サイドバーのショートカットを使用", - "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "下流が非ストリーミング要求の場合、この Claude チャンネルと上流の間でストリーミングを使用します", + "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "ダウンストリームリクエストが非ストリーミングの場合、この Claude チャンネルとアップストリームの間でストリーミングを使用します", "Use the full-width table to scan prices, then select a row to edit it here.": "表で価格を確認し、行を選択してここで編集します。", "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "料金グループ表で倍率と、トークン作成ドロップダウンに表示するかどうかを管理します。", "Use this token for API authentication": "API認証にはこのトークンを使用してください", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index acaa61cbee1a..04bb388b35a1 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -4229,7 +4229,7 @@ "Use Passkey to sign in without entering your password.": "Sử dụng Khóa truy cập để đăng nhập mà không cần nhập mật khẩu của bạn.", "Use secure connection when sending emails": "Sử dụng kết nối an toàn khi gửi email", "Use sidebar shortcut": "Sử dụng phím tắt thanh bên", - "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "Dùng truyền phát giữa kênh Claude này và thượng nguồn cho các yêu cầu phía dưới không truyền phát", + "Use streaming between this Claude channel and upstream for non-streaming downstream requests": "Dùng truyền phát giữa kênh Claude này và thượng nguồn cho các yêu cầu hạ nguồn không truyền phát", "Use the full-width table to scan prices, then select a row to edit it here.": "Duyệt giá trong bảng, rồi chọn một hàng để chỉnh sửa tại đây.", "Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.": "Dùng bảng nhóm định giá để quản lý tỷ lệ và việc nhóm có xuất hiện trong danh sách tạo token hay không.", "Use this token for API authentication": "Sử dụng token này để xác thực API",