diff --git a/controller/channel-test.go b/controller/channel-test.go index 4ba3698bd54c..90fe70395467 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -247,6 +247,13 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te info.IsChannelTest = true info.InitChannelMeta(c) + // 与正常请求路径 (TextHelper) 保持一致:渠道开启 ForceStream 时, + // 强制上游流式并缓冲成单 JSON,使只支持流式的上游也能通过非流式测试。 + // 仅对 chat completions 生效(OaiStreamBufferHandler 只处理该格式)。 + if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok { + relay.ApplyForceStream(info, generalReq) + } + err = attachTestBillingRequestInput(info, request) if err != nil { return testResult{ diff --git a/dto/channel_settings.go b/dto/channel_settings.go index dc03773998d8..2fea8057a0e0 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -9,6 +9,7 @@ import ( type ChannelSettings struct { ForceFormat bool `json:"force_format,omitempty"` ThinkingToContent bool `json:"thinking_to_content,omitempty"` + ForceStream bool `json:"force_stream,omitempty"` Proxy string `json:"proxy"` PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 2c230107de37..0e7fa728349a 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -645,7 +645,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom case relayconstant.RelayModeResponsesCompact: usage, err = OaiResponsesCompactionHandler(c, resp) default: - if info.IsStream { + if info.ForceStreamBuffer { + usage, err = OaiStreamBufferHandler(c, info, resp) + } else if info.IsStream { usage, err = OaiStreamHandler(c, info, resp) } else { usage, err = OpenaiHandler(c, info, resp) diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index d10176b58fe1..0628d8d2a695 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -208,3 +209,30 @@ func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamR } _ = helper.ResponseChunkData(c, streamResponse, data) } + +// markContentFilterReject checks choices for a content_filter finish reason +// and sets the admin reject-reason context key if found. Shared between +// OpenaiHandler and OaiStreamBufferHandler. +func markContentFilterReject(c *gin.Context, choices []dto.OpenAITextResponseChoice) { + for _, choice := range choices { + if choice.FinishReason == constant.FinishReasonContentFilter { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "openai_finish_reason=content_filter") + break + } + } +} + +// marshalTextResponse converts an OpenAITextResponse to the client's expected +// relay format (Claude / Gemini / OpenAI) and marshals it. Shared between +// OpenaiHandler and OaiStreamBufferHandler so the conversion logic stays in +// one place. +func marshalTextResponse(textResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) ([]byte, error) { + switch info.RelayFormat { + case types.RelayFormatClaude: + return common.Marshal(service.ResponseOpenAI2Claude(textResponse, info)) + case types.RelayFormatGemini: + return common.Marshal(service.ResponseOpenAI2Gemini(textResponse, info)) + default: + return common.Marshal(textResponse) + } +} diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index de40fe7071fc..751bb9502dea 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -220,12 +220,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) } - for _, choice := range simpleResponse.Choices { - if choice.FinishReason == constant.FinishReasonContentFilter { - common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "openai_finish_reason=content_filter") - break - } - } + markContentFilterReject(c, simpleResponse.Choices) forceFormat := false if info.ChannelSetting.ForceFormat { @@ -251,8 +246,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo applyUsagePostProcessing(info, &simpleResponse.Usage, responseBody) - switch info.RelayFormat { - case types.RelayFormatOpenAI: + if info.RelayFormat == types.RelayFormatOpenAI { if usageModified { var bodyMap map[string]interface{} err = common.Unmarshal(responseBody, &bodyMap) @@ -267,23 +261,13 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo if err != nil { return nil, types.NewError(err, types.ErrorCodeBadResponseBody) } - } else { - break } - case types.RelayFormatClaude: - claudeResp := service.ResponseOpenAI2Claude(&simpleResponse, info) - claudeRespStr, err := common.Marshal(claudeResp) - if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) - } - responseBody = claudeRespStr - case types.RelayFormatGemini: - geminiResp := service.ResponseOpenAI2Gemini(&simpleResponse, info) - geminiRespStr, err := common.Marshal(geminiResp) - if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } else { + converted, marshalErr := marshalTextResponse(&simpleResponse, info) + if marshalErr != nil { + return nil, types.NewError(marshalErr, types.ErrorCodeBadResponseBody) } - responseBody = geminiRespStr + responseBody = converted } service.IOCopyBytesGracefully(c, resp, responseBody) diff --git a/relay/channel/openai/relay-stream-buffer.go b/relay/channel/openai/relay-stream-buffer.go new file mode 100644 index 000000000000..824d706071ab --- /dev/null +++ b/relay/channel/openai/relay-stream-buffer.go @@ -0,0 +1,339 @@ +package openai + +import ( + "bufio" + "fmt" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// OaiStreamBufferHandler consumes a streaming SSE response from an upstream +// that only supports streaming, buffers it entirely, and returns a single +// non-streaming JSON response to the client. It is used when the channel has +// ForceStream enabled and the client sent a non-streaming request. +func OaiStreamBufferHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + + defer service.CloseResponseBodyGracefully(resp) + + acc := newStreamAccumulator() + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 256*1024), 10*1024*1024) + for scanner.Scan() { + data, ok := parseSSEData(scanner.Text()) + if !ok { + continue + } + + info.ReceivedResponseCount++ + info.SetFirstResponseTime() + + oaiErr, err := acc.mergeChunk(data) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if oaiErr != nil { + return nil, types.WithOpenAIError(*oaiErr, http.StatusBadGateway) + } + } + + if err := scanner.Err(); err != nil { + return nil, types.NewOpenAIError(fmt.Errorf("error reading stream: %w", err), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if acc.isEmpty() { + return nil, types.NewOpenAIError(fmt.Errorf("empty stream response"), types.ErrorCodeBadResponse, http.StatusBadGateway) + } + + usage := acc.usage + if !acc.hasUsage { + usage = service.ResponseText2Usage(c, acc.fullText(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + usage.CompletionTokens += acc.toolCount() * 7 + } + applyUsagePostProcessing(info, usage, common.StringToByteSlice(acc.lastUsageRawData)) + + textResponse := acc.assemble(info.UpstreamModelName, usage) + + markContentFilterReject(c, textResponse.Choices) + + responseBody, marshalErr := marshalTextResponse(&textResponse, info) + if marshalErr != nil { + return nil, types.NewError(marshalErr, types.ErrorCodeBadResponseBody) + } + + if info.StreamStatus == nil { + info.StreamStatus = relaycommon.NewStreamStatus() + } + info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil) + + // Write as a standard JSON response. Do NOT copy upstream headers (which + // are text/event-stream for this forced-stream response) — the client + // expects application/json. + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(responseBody))) + c.Writer.WriteHeader(http.StatusOK) + _, _ = c.Writer.Write(responseBody) + c.Writer.Flush() + + return usage, nil +} + +// --------------------------------------------------------------------------- +// Delta accumulator +// --------------------------------------------------------------------------- + +type accumulatedChoice struct { + index int + role string + content strings.Builder + reasoningContent strings.Builder + finishReason string + toolCalls []*dto.ToolCallResponse + toolCallIndexMap map[int]int // maps delta tool-call index -> position in toolCalls slice +} + +type streamAccumulator struct { + responseId string + model string + created int64 + choices map[int]*accumulatedChoice + choiceOrder []int + usage *dto.Usage + hasUsage bool + lastUsageRawData string // raw SSE data of the chunk that carried usage, for cached-token extraction + maxToolCallsPerChk int // max len(Delta.ToolCalls) across all chunks/choices (matches ProcessStreamResponse semantics) + textForUsage strings.Builder +} + +func newStreamAccumulator() *streamAccumulator { + return &streamAccumulator{ + choices: make(map[int]*accumulatedChoice), + usage: &dto.Usage{}, + } +} + +func (a *streamAccumulator) isEmpty() bool { + return len(a.choices) == 0 +} + +func (a *streamAccumulator) getOrCreateChoice(index int) *accumulatedChoice { + ch, ok := a.choices[index] + if !ok { + ch = &accumulatedChoice{ + index: index, + toolCallIndexMap: make(map[int]int), + } + a.choices[index] = ch + a.choiceOrder = append(a.choiceOrder, index) + } + return ch +} + +// streamChunk embeds ChatCompletionsStreamResponse and adds an optional Error +// field so each SSE data payload is parsed exactly once. +type streamChunk struct { + dto.ChatCompletionsStreamResponse + Error any `json:"error"` +} + +// mergeChunk parses and accumulates a single SSE data payload. It also probes +// for an upstream error in the same pass. Returns (oaiError, parseErr): when +// oaiError is non-nil the chunk contained an upstream error that should be +// surfaced to the client. +func (a *streamAccumulator) mergeChunk(data string) (*types.OpenAIError, error) { + var chunk streamChunk + if err := common.UnmarshalJsonStr(data, &chunk); err != nil { + return nil, err + } + + if oaiErr := dto.GetOpenAIError(chunk.Error); oaiErr != nil && oaiErr.Type != "" { + return oaiErr, nil + } + + if a.responseId == "" { + a.responseId = chunk.Id + } + if a.model == "" { + a.model = chunk.Model + } + if a.created == 0 { + a.created = chunk.Created + } + + if service.ValidUsage(chunk.Usage) { + a.usage = chunk.Usage + a.hasUsage = true + a.lastUsageRawData = data + } + + for i := range chunk.Choices { + choice := &chunk.Choices[i] + ch := a.getOrCreateChoice(choice.Index) + + if choice.Delta.Role != "" { + ch.role = choice.Delta.Role + } + + contentStr := choice.Delta.GetContentString() + if contentStr != "" { + ch.content.WriteString(contentStr) + a.textForUsage.WriteString(contentStr) + } + + reasoningStr := choice.Delta.GetReasoningContent() + if reasoningStr != "" { + ch.reasoningContent.WriteString(reasoningStr) + a.textForUsage.WriteString(reasoningStr) + } + + if len(choice.Delta.ToolCalls) > 0 { + if len(choice.Delta.ToolCalls) > a.maxToolCallsPerChk { + a.maxToolCallsPerChk = len(choice.Delta.ToolCalls) + } + for j := range choice.Delta.ToolCalls { + a.mergeToolCall(ch, &choice.Delta.ToolCalls[j]) + } + } + + if choice.FinishReason != nil && *choice.FinishReason != "" { + ch.finishReason = *choice.FinishReason + } + } + + return nil, nil +} + +func (a *streamAccumulator) mergeToolCall(ch *accumulatedChoice, tc *dto.ToolCallResponse) { + deltaIdx := 0 + if tc.Index != nil { + deltaIdx = *tc.Index + } + + pos, exists := ch.toolCallIndexMap[deltaIdx] + if !exists { + newTC := &dto.ToolCallResponse{ + Function: dto.FunctionResponse{}, + } + ch.toolCalls = append(ch.toolCalls, newTC) + pos = len(ch.toolCalls) - 1 + ch.toolCallIndexMap[deltaIdx] = pos + } + + target := ch.toolCalls[pos] + if tc.ID != "" { + target.ID = tc.ID + } + if tc.Type != nil { + target.Type = tc.Type + } + if tc.Function.Name != "" { + target.Function.Name = tc.Function.Name + } + if tc.Function.Description != "" { + target.Function.Description = tc.Function.Description + } + if tc.Function.Arguments != "" { + target.Function.Arguments += tc.Function.Arguments + } + a.textForUsage.WriteString(tc.Function.Name) + a.textForUsage.WriteString(tc.Function.Arguments) +} + +// assemble builds the final non-streaming OpenAITextResponse from accumulated +// deltas. +func (a *streamAccumulator) assemble(model string, usage *dto.Usage) dto.OpenAITextResponse { + choices := make([]dto.OpenAITextResponseChoice, 0, len(a.choiceOrder)) + for _, idx := range a.choiceOrder { + ch := a.choices[idx] + msg := dto.Message{ + Role: ch.role, + Content: ch.content.String(), + } + if ch.reasoningContent.Len() > 0 { + rc := ch.reasoningContent.String() + msg.ReasoningContent = &rc + } + if len(ch.toolCalls) > 0 { + msg.SetToolCalls(ch.toolCalls) + } + + choices = append(choices, dto.OpenAITextResponseChoice{ + Index: ch.index, + Message: msg, + FinishReason: ch.finishReason, + }) + } + + respModel := a.model + if respModel == "" { + respModel = model + } + + return dto.OpenAITextResponse{ + Id: a.responseId, + Model: respModel, + Object: "chat.completion", + Created: a.created, + Choices: choices, + Usage: *usage, + } +} + +func (a *streamAccumulator) fullText() string { + return a.textForUsage.String() +} + +func (a *streamAccumulator) toolCount() int { + return a.maxToolCallsPerChk +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// parseSSEData extracts the JSON payload from an SSE "data:" line. Returns +// ("", false) for non-data lines, empty data, or [DONE]. +func parseSSEData(line string) (string, bool) { + if !strings.HasPrefix(line, "data:") { + return "", false + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" || data == "[DONE]" { + return "", false + } + return data, true +} + +// accumulateSSEChunks is a pure helper that feeds SSE data payloads extracted +// from rawSSE into a fresh accumulator. Used by tests to exercise delta merging +// without HTTP plumbing. +func accumulateSSEChunks(rawSSE string) (*streamAccumulator, error) { + acc := newStreamAccumulator() + scanner := bufio.NewScanner(strings.NewReader(rawSSE)) + scanner.Buffer(make([]byte, 0, 256*1024), 10*1024*1024) + for scanner.Scan() { + data, ok := parseSSEData(scanner.Text()) + if !ok { + continue + } + oaiErr, err := acc.mergeChunk(data) + if err != nil { + return nil, err + } + if oaiErr != nil { + return acc, nil + } + } + return acc, nil +} diff --git a/relay/channel/openai/relay-stream-buffer_test.go b/relay/channel/openai/relay-stream-buffer_test.go new file mode 100644 index 000000000000..d33adb77e629 --- /dev/null +++ b/relay/channel/openai/relay-stream-buffer_test.go @@ -0,0 +1,392 @@ +package openai + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sseLines joins data payloads into raw SSE text the way an upstream would +// send it: each payload on its own "data: ...\n\n" line. +func sseLines(payloads ...string) string { + var b strings.Builder + for _, p := range payloads { + b.WriteString("data: ") + b.WriteString(p) + b.WriteString("\n\n") + } + return b.String() +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := common.Marshal(v) + require.NoError(t, err) + return string(b) +} + +func intPtr(v int) *int { return &v } +func strPtr(v string) *string { return &v } + +// --------------------------------------------------------------------------- +// Content accumulation +// --------------------------------------------------------------------------- + +func TestAccumulate_ContentOnly(t *testing.T) { + chunks := sseLines( + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "chatcmpl-1", Model: "gpt-4o", Created: 1700000000, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}}, + }, + }), + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: strPtr("Hello")}}, + }, + }), + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: strPtr(", world!")}, FinishReason: strPtr("stop")}, + }, + }), + ) + + acc, err := accumulateSSEChunks(chunks) + require.NoError(t, err) + require.False(t, acc.isEmpty()) + + resp := acc.assemble("gpt-4o", &dto.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}) + + assert.Equal(t, "chatcmpl-1", resp.Id) + assert.Equal(t, "chat.completion", resp.Object) + assert.Equal(t, "gpt-4o", resp.Model) + require.Len(t, resp.Choices, 1) + assert.Equal(t, "assistant", resp.Choices[0].Message.Role) + assert.Equal(t, "Hello, world!", resp.Choices[0].Message.StringContent()) + assert.Equal(t, "stop", resp.Choices[0].FinishReason) +} + +func TestAccumulate_ReasoningContent(t *testing.T) { + chunks := sseLines( + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "r1", Model: "o1", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}}, + }, + }), + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: strPtr("Thinking...")}}, + }, + }), + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: strPtr("Answer")}, FinishReason: strPtr("stop")}, + }, + }), + ) + + acc, err := accumulateSSEChunks(chunks) + require.NoError(t, err) + + resp := acc.assemble("o1", &dto.Usage{}) + require.Len(t, resp.Choices, 1) + assert.Equal(t, "Answer", resp.Choices[0].Message.StringContent()) + assert.Equal(t, "Thinking...", resp.Choices[0].Message.GetReasoningContent()) +} + +// --------------------------------------------------------------------------- +// Tool call accumulation +// --------------------------------------------------------------------------- + +func TestAccumulate_ToolCallSplitArguments(t *testing.T) { + chunks := sseLines( + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "tc1", Model: "gpt-4o", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + ToolCalls: []dto.ToolCallResponse{ + {Index: intPtr(0), ID: "call_abc", Type: "function", Function: dto.FunctionResponse{Name: "get_weather"}}, + }, + }}, + }, + }), + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + ToolCalls: []dto.ToolCallResponse{ + {Index: intPtr(0), Function: dto.FunctionResponse{Arguments: `{"loc`}}, + }, + }}, + }, + }), + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + ToolCalls: []dto.ToolCallResponse{ + {Index: intPtr(0), Function: dto.FunctionResponse{Arguments: `ation":"NYC"}`}}, + }, + }, FinishReason: strPtr("tool_calls")}, + }, + }), + ) + + acc, err := accumulateSSEChunks(chunks) + require.NoError(t, err) + + resp := acc.assemble("gpt-4o", &dto.Usage{}) + require.Len(t, resp.Choices, 1) + + toolCalls := resp.Choices[0].Message.ParseToolCalls() + require.Len(t, toolCalls, 1) + assert.Equal(t, "call_abc", toolCalls[0].ID) + assert.Equal(t, "get_weather", toolCalls[0].Function.Name) + assert.Equal(t, `{"location":"NYC"}`, toolCalls[0].Function.Arguments) + assert.Equal(t, "tool_calls", resp.Choices[0].FinishReason) +} + +func TestAccumulate_MultipleToolCalls(t *testing.T) { + chunks := sseLines( + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "mtc1", Model: "gpt-4o", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + ToolCalls: []dto.ToolCallResponse{ + {Index: intPtr(0), ID: "call_a", Type: "function", Function: dto.FunctionResponse{Name: "fn_a"}}, + {Index: intPtr(1), ID: "call_b", Type: "function", Function: dto.FunctionResponse{Name: "fn_b"}}, + }, + }}, + }, + }), + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + ToolCalls: []dto.ToolCallResponse{ + {Index: intPtr(0), Function: dto.FunctionResponse{Arguments: `{"x":1}`}}, + {Index: intPtr(1), Function: dto.FunctionResponse{Arguments: `{"y":2}`}}, + }, + }, FinishReason: strPtr("tool_calls")}, + }, + }), + ) + + acc, err := accumulateSSEChunks(chunks) + require.NoError(t, err) + + resp := acc.assemble("gpt-4o", &dto.Usage{}) + require.Len(t, resp.Choices, 1) + + toolCalls := resp.Choices[0].Message.ParseToolCalls() + require.Len(t, toolCalls, 2) + assert.Equal(t, "call_a", toolCalls[0].ID) + assert.Equal(t, `{"x":1}`, toolCalls[0].Function.Arguments) + assert.Equal(t, "call_b", toolCalls[1].ID) + assert.Equal(t, `{"y":2}`, toolCalls[1].Function.Arguments) + + // maxToolCallsPerChk should be 2 (first chunk had 2 tool calls) + assert.Equal(t, 2, acc.toolCount()) +} + +// --------------------------------------------------------------------------- +// Multiple choices (n > 1) +// --------------------------------------------------------------------------- + +func TestAccumulate_MultipleChoices(t *testing.T) { + chunks := sseLines( + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "mc1", Model: "gpt-4o", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant", Content: strPtr("A")}}, + {Index: 1, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant", Content: strPtr("B")}}, + }, + }), + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: strPtr("1")}, FinishReason: strPtr("stop")}, + {Index: 1, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: strPtr("2")}, FinishReason: strPtr("stop")}, + }, + }), + ) + + acc, err := accumulateSSEChunks(chunks) + require.NoError(t, err) + + resp := acc.assemble("gpt-4o", &dto.Usage{}) + require.Len(t, resp.Choices, 2) + assert.Equal(t, 0, resp.Choices[0].Index) + assert.Equal(t, "A1", resp.Choices[0].Message.StringContent()) + assert.Equal(t, 1, resp.Choices[1].Index) + assert.Equal(t, "B2", resp.Choices[1].Message.StringContent()) +} + +// --------------------------------------------------------------------------- +// Usage extraction +// --------------------------------------------------------------------------- + +func TestAccumulate_UsageFromUpstream(t *testing.T) { + usage := &dto.Usage{PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150} + usageChunk := mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "u1", Model: "gpt-4o", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant", Content: strPtr("hi")}, FinishReason: strPtr("stop")}, + }, + Usage: usage, + }) + chunks := sseLines(usageChunk) + + acc, err := accumulateSSEChunks(chunks) + require.NoError(t, err) + require.True(t, acc.hasUsage) + + assert.Equal(t, 100, acc.usage.PromptTokens) + assert.Equal(t, 50, acc.usage.CompletionTokens) + // lastUsageRawData should be the raw SSE data of the usage-bearing chunk + assert.Equal(t, usageChunk, acc.lastUsageRawData) +} + +func TestAccumulate_UsageFallbackFlag(t *testing.T) { + chunks := sseLines( + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "uf1", Model: "gpt-4o", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant", Content: strPtr("hello")}, FinishReason: strPtr("stop")}, + }, + }), + ) + + acc, err := accumulateSSEChunks(chunks) + require.NoError(t, err) + assert.False(t, acc.hasUsage) + assert.Equal(t, "hello", acc.fullText()) + assert.Empty(t, acc.lastUsageRawData) +} + +// --------------------------------------------------------------------------- +// Error detection (now integrated into mergeChunk) +// --------------------------------------------------------------------------- + +func TestAccumulate_UpstreamErrorInChunk(t *testing.T) { + errorChunk := `{"error":{"message":"rate limit exceeded","type":"rate_limit_error","code":"429"}}` + chunks := sseLines( + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "err1", Model: "gpt-4o", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant", Content: strPtr("partial")}}, + }, + }), + errorChunk, + ) + + acc, err := accumulateSSEChunks(chunks) + require.NoError(t, err) + // The error chunk is detected during mergeChunk but accumulateSSEChunks + // returns the acc without surfacing the error (the handler checks it). + // Verify the non-error chunk was accumulated. + assert.False(t, acc.isEmpty()) +} + +func TestMergeChunk_ReturnsErrorForErrorChunk(t *testing.T) { + acc := newStreamAccumulator() + oaiErr, err := acc.mergeChunk(`{"error":{"message":"rate limit","type":"rate_limit_error"}}`) + require.NoError(t, err) + require.NotNil(t, oaiErr) + assert.Equal(t, "rate_limit_error", oaiErr.Type) + assert.Equal(t, "rate limit", oaiErr.Message) +} + +func TestMergeChunk_NoErrorForNormalChunk(t *testing.T) { + acc := newStreamAccumulator() + oaiErr, err := acc.mergeChunk(mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "ok1", + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: strPtr("ok")}}, + }, + })) + require.NoError(t, err) + assert.Nil(t, oaiErr) +} + +// --------------------------------------------------------------------------- +// Empty stream +// --------------------------------------------------------------------------- + +func TestAccumulate_EmptyStream(t *testing.T) { + acc, err := accumulateSSEChunks("") + require.NoError(t, err) + assert.True(t, acc.isEmpty()) +} + +func TestAccumulate_OnlyDoneMarker(t *testing.T) { + acc, err := accumulateSSEChunks("data: [DONE]\n\n") + require.NoError(t, err) + assert.True(t, acc.isEmpty()) +} + +// --------------------------------------------------------------------------- +// SSE parsing helpers +// --------------------------------------------------------------------------- + +func TestParseSSEData(t *testing.T) { + tests := []struct { + line string + data string + ok bool + }{ + {"data: {\"id\":\"1\"}", `{"id":"1"}`, true}, + {"data:[DONE]", "", false}, + {": comment", "", false}, + {"event: ping", "", false}, + {"data: ", "", false}, + } + for _, tt := range tests { + data, ok := parseSSEData(tt.line) + assert.Equal(t, tt.ok, ok, "line: %q", tt.line) + if ok { + assert.Equal(t, tt.data, data) + } + } +} + +// --------------------------------------------------------------------------- +// Assembled JSON shape +// --------------------------------------------------------------------------- + +func TestAssemble_ProducesValidJSON(t *testing.T) { + chunks := sseLines( + mustJSON(t, dto.ChatCompletionsStreamResponse{ + Id: "json1", Model: "gpt-4o", Created: 1700000000, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant", Content: strPtr("test")}, FinishReason: strPtr("stop")}, + }, + Usage: &dto.Usage{PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8}, + }), + ) + + acc, err := accumulateSSEChunks(chunks) + require.NoError(t, err) + + resp := acc.assemble("gpt-4o", acc.usage) + body, err := common.Marshal(resp) + require.NoError(t, err) + + // Verify the JSON has the expected shape of a non-streaming chat completion + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &raw)) + assert.Contains(t, raw, "id") + assert.Contains(t, raw, "object") + assert.Contains(t, raw, "model") + assert.Contains(t, raw, "choices") + assert.Contains(t, raw, "usage") + + var obj string + require.NoError(t, json.Unmarshal(raw["object"], &obj)) + assert.Equal(t, "chat.completion", obj) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 9f460ce5c6a7..ee41f2d8b340 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -99,6 +99,7 @@ type RelayInfo struct { //SendLastReasoningResponse bool IsStream bool IsGeminiBatchEmbedding bool + ForceStreamBuffer bool // true when client requested non-stream but upstream is forced to stream IsPlayground bool UsePrice bool RelayMode int diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index a68cfe730f60..e6034567090f 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -22,6 +22,26 @@ import ( "github.com/gin-gonic/gin" ) +// ApplyForceStream forces stream:true upstream and marks the response for SSE +// buffering when the channel has ForceStream enabled, the client requested +// non-streaming, and pass-through is not active. It is shared by the normal +// relay path (TextHelper) and the channel test path (testChannel) so that +// stream-only upstreams behave identically in both. +// +// ForceStreamBuffer is reassigned unconditionally (not just set to true) so +// that a RelayInfo reused across retries onto a channel that does not force +// streaming does not keep a stale buffering flag from a previous attempt. +func ApplyForceStream(info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) { + active := info.ChannelSetting.ForceStream && + !info.ChannelSetting.PassThroughBodyEnabled && + !model_setting.GetGlobalSettings().PassThroughRequestEnabled && + !lo.FromPtrOr(request.Stream, false) + info.ForceStreamBuffer = active + if active { + request.Stream = common.GetPointer(true) + } +} + func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { info.InitChannelMeta(c) @@ -35,6 +55,8 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return types.NewError(fmt.Errorf("failed to copy request to GeneralOpenAIRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } + ApplyForceStream(info, request) + if request.WebSearchOptions != nil { c.Set("chat_completion_web_search_context_size", request.WebSearchOptions.SearchContextSize) } diff --git a/relay/compatible_handler_test.go b/relay/compatible_handler_test.go new file mode 100644 index 000000000000..2987615ca701 --- /dev/null +++ b/relay/compatible_handler_test.go @@ -0,0 +1,105 @@ +package relay + +import ( + "testing" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/model_setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestApplyForceStream guards the invariant that ForceStream only activates +// when the channel opts in, the client requested non-streaming, and neither +// pass-through mode is active. When activated it must flip stream to true and +// set ForceStreamBuffer; otherwise the request stream must be left untouched. +func TestApplyForceStream(t *testing.T) { + // ApplyForceStream reads the global pass-through flag, so save and restore it. + original := model_setting.GetGlobalSettings().PassThroughRequestEnabled + t.Cleanup(func() { model_setting.GetGlobalSettings().PassThroughRequestEnabled = original }) + + ptrBool := func(v bool) *bool { return &v } + + tests := []struct { + name string + forceStream bool + passThroughBody bool + globalPassThrough bool + inStream *bool // incoming request.Stream + wantActive bool + }{ + {name: "active when force stream and non-stream client", forceStream: true, inStream: ptrBool(false), wantActive: true}, + {name: "active when force stream and stream nil", forceStream: true, inStream: nil, wantActive: true}, + {name: "inactive when client already streaming", forceStream: true, inStream: ptrBool(true), wantActive: false}, + {name: "inactive when force stream disabled", forceStream: false, inStream: ptrBool(false), wantActive: false}, + {name: "inactive when channel pass-through enabled", forceStream: true, passThroughBody: true, inStream: ptrBool(false), wantActive: false}, + {name: "inactive when global pass-through enabled", forceStream: true, globalPassThrough: true, inStream: ptrBool(false), wantActive: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model_setting.GetGlobalSettings().PassThroughRequestEnabled = tt.globalPassThrough + + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelSetting: dto.ChannelSettings{ + ForceStream: tt.forceStream, + PassThroughBodyEnabled: tt.passThroughBody, + }, + }, + } + req := &dto.GeneralOpenAIRequest{Stream: tt.inStream} + + ApplyForceStream(info, req) + + require.Equal(t, tt.wantActive, info.ForceStreamBuffer) + if tt.wantActive { + require.NotNil(t, req.Stream) + assert.True(t, *req.Stream, "stream must be forced true when active") + } else { + // Inactive: the stream pointer must be untouched. + if tt.inStream == nil { + assert.Nil(t, req.Stream) + } else { + require.NotNil(t, req.Stream) + assert.Equal(t, *tt.inStream, *req.Stream) + } + } + }) + } +} + +// TestApplyForceStream_ClearsStaleFlagOnRetry guards the retry-reuse invariant: +// RelayInfo is reused across retries onto different channels. If the first +// channel forced streaming (setting ForceStreamBuffer=true) and then failed, +// retrying onto a channel that does not force streaming must clear the flag — +// otherwise the non-forced upstream's plain JSON response would be routed into +// OaiStreamBufferHandler and misparsed as SSE. +func TestApplyForceStream_ClearsStaleFlagOnRetry(t *testing.T) { + original := model_setting.GetGlobalSettings().PassThroughRequestEnabled + t.Cleanup(func() { model_setting.GetGlobalSettings().PassThroughRequestEnabled = original }) + + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelSetting: dto.ChannelSettings{ForceStream: true}, + }, + } + + // First attempt: ForceStream channel activates buffering. + firstReq := &dto.GeneralOpenAIRequest{} + ApplyForceStream(info, firstReq) + require.True(t, info.ForceStreamBuffer, "first attempt on ForceStream channel must activate") + require.NotNil(t, firstReq.Stream) + require.True(t, *firstReq.Stream) + + // Simulate a retry onto a channel that does not force streaming: the same + // RelayInfo is reused, but ChannelSetting now reflects the new channel. + info.ChannelSetting.ForceStream = false + secondReq := &dto.GeneralOpenAIRequest{} + ApplyForceStream(info, secondReq) + + assert.False(t, info.ForceStreamBuffer, "stale buffering flag must be cleared on retry to a non-ForceStream channel") + assert.Nil(t, secondReq.Stream, "non-forced request stream must remain untouched") +} 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 383d1aee5c2f..221185b6a2cd 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 @@ -281,6 +281,7 @@ const SENSITIVE_FORM_FIELDS = [ 'azure_responses_version', 'force_format', 'thinking_to_content', + 'force_stream', 'proxy', 'pass_through_body_enabled', 'system_prompt', @@ -726,6 +727,7 @@ export function ChannelMutateDrawer({ const currentHeaderOverride = form.watch('header_override') const currentForceFormat = form.watch('force_format') const currentThinkingToContent = form.watch('thinking_to_content') + const currentForceStream = form.watch('force_stream') const currentPassThroughBodyEnabled = form.watch('pass_through_body_enabled') const currentDisableTaskPollingSleep = form.watch( 'disable_task_polling_sleep' @@ -993,6 +995,7 @@ export function ChannelMutateDrawer({ const extraSettingsConfigured = Boolean( currentForceFormat || currentThinkingToContent || + currentForceStream || currentPassThroughBodyEnabled || currentDisableTaskPollingSleep || currentProxy?.trim() || @@ -4063,6 +4066,35 @@ export function ChannelMutateDrawer({ )} /> + {[1, 3, 8, CHANNEL_TYPE_ADVANCED_CUSTOM].includes( + currentType + ) && ( + ( + +
+ + {t('Force Stream')} + + + {t( + 'Force streaming upstream and buffer the response as non-streaming JSON. Useful for upstream providers that only support streaming. Incompatible with Pass Through Body.' + )} + +
+ + + +
+ )} + /> + )} + >([ 'upstream_model_update_check_enabled', 'upstream_model_update_auto_sync_enabled', 'upstream_model_update_ignored_models', + 'force_stream', ]) export function isAdvancedSettingsField( diff --git a/web/default/src/features/channels/lib/channel-form.ts b/web/default/src/features/channels/lib/channel-form.ts index 33a5fb707a92..d523ef45b5fa 100644 --- a/web/default/src/features/channels/lib/channel-form.ts +++ b/web/default/src/features/channels/lib/channel-form.ts @@ -187,6 +187,7 @@ 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(), + force_stream: z.boolean().optional(), proxy: z.string().optional(), pass_through_body_enabled: z.boolean().optional(), system_prompt: z.string().optional(), @@ -327,6 +328,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = { // Channel extra settings force_format: false, thinking_to_content: false, + force_stream: false, proxy: '', pass_through_body_enabled: false, system_prompt: '', @@ -365,6 +367,7 @@ export function transformChannelToFormDefaults( let extraSettings = { force_format: false, thinking_to_content: false, + force_stream: false, proxy: '', pass_through_body_enabled: false, system_prompt: '', @@ -377,6 +380,7 @@ export function transformChannelToFormDefaults( extraSettings = { force_format: parsed.force_format || false, thinking_to_content: parsed.thinking_to_content || false, + force_stream: parsed.force_stream || false, proxy: parsed.proxy || '', pass_through_body_enabled: parsed.pass_through_body_enabled || false, system_prompt: parsed.system_prompt || '', @@ -494,6 +498,7 @@ function buildSettingJSON(formData: ChannelFormValues): string { const settingObj = { force_format: formData.force_format || false, thinking_to_content: formData.thinking_to_content || false, + force_stream: formData.force_stream || false, proxy: formData.proxy || '', pass_through_body_enabled: formData.pass_through_body_enabled || false, system_prompt: formData.system_prompt || '', diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index 6d9b7b1d660b..ea04eb6f27c4 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -82,6 +82,7 @@ export type Channel = z.infer export interface ChannelSettings { force_format?: boolean thinking_to_content?: boolean + force_stream?: boolean proxy?: string pass_through_body_enabled?: boolean system_prompt?: string diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 70d3140533e6..3460b5bdf5a2 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2013,6 +2013,8 @@ "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 Stream": "Force Stream", + "Force streaming upstream and buffer the response as non-streaming JSON. Useful for upstream providers that only support streaming. Incompatible with Pass Through Body.": "Force streaming upstream and buffer the response as non-streaming JSON. Useful for upstream providers that only support streaming. Incompatible with Pass Through Body.", "Force-disabled two-factor authentication for the user": "Force-disabled two-factor authentication for the user", "Forest Whisper": "Forest Whisper", "Forgot password": "Forgot password", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index e30af4f8a53c..8024b3982408 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -2013,6 +2013,8 @@ "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 Stream": "Forcer le streaming", + "Force streaming upstream and buffer the response as non-streaming JSON. Useful for upstream providers that only support streaming. Incompatible with Pass Through Body.": "Forcer le streaming en amont et mettre en mémoire tampon la réponse sous forme de JSON non diffusé. Utile pour les fournisseurs en amont qui ne prennent en charge que le streaming. Incompatible avec le corps de requête transparent.", "Force-disabled two-factor authentication for the user": "Authentification à deux facteurs désactivée de force pour l'utilisateur", "Forest Whisper": "Murmure forestier", "Forgot password": "Mot de passe oublié", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index bf1187d34983..386c8b2f4efd 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -2013,6 +2013,8 @@ "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 Stream": "ストリーム強制", + "Force streaming upstream and buffer the response as non-streaming JSON. Useful for upstream providers that only support streaming. Incompatible with Pass Through Body.": "上流のストリーミングを強制し、レスポンスを非ストリーミング JSON としてバッファリングします。ストリーミングのみをサポートする上流プロバイダーに役立ちます。パススルーボディとは互換性がありません。", "Force-disabled two-factor authentication for the user": "ユーザーの二段階認証を強制的に無効化しました", "Forest Whisper": "フォレストウィスパー", "Forgot password": "パスワードを忘れた場合", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 2feffdc65272..c7ebc9085eeb 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -2013,6 +2013,8 @@ "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 Stream": "Принудительный стриминг", + "Force streaming upstream and buffer the response as non-streaming JSON. Useful for upstream providers that only support streaming. Incompatible with Pass Through Body.": "Принудительная потоковая передача вверх по потоку и буферизация ответа как непотокового JSON. Полезно для провайдеров, поддерживающих только потоковую передачу. Несовместимо со сквозной передачей тела запроса.", "Force-disabled two-factor authentication for the user": "Двухфакторная аутентификация пользователя принудительно отключена", "Forest Whisper": "Лесной шёпот", "Forgot password": "Забыли пароль", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 0b440e5dc149..10adc9ffb191 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -2013,6 +2013,8 @@ "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 Stream": "Bắt buộc luồng", + "Force streaming upstream and buffer the response as non-streaming JSON. Useful for upstream providers that only support streaming. Incompatible with Pass Through Body.": "Bắt buộc phát trực tuyến lên tuyến và lưu đệm phản hồi dưới dạng JSON không phát trực tuyến. Hữu ích cho các nhà cung cấp chỉ hỗ trợ phát trực tuyến. Không tương thích với truyền qua nội dung yêu cầu.", "Force-disabled two-factor authentication for the user": "Đã buộc tắt xác thực hai yếu tố của người dùng", "Forest Whisper": "Tiếng thì thầm rừng cây", "Forgot password": "Quên mật khẩu", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index b46d47d716d0..3ad980829b7d 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -1500,7 +1500,7 @@ "Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "每個檔位支援 0~2 個條件(針對 len、p、c),最後一檔為兜底檔無需條件。建議條件使用 len(完整輸入長度,含緩存命中),避免緩存命中降低 p 導致檔位誤判。", "Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每個檔位最多支援 2 個條件;最後一個檔位是不帶條件的兜底檔。建議使用完整輸入長度作為檔位條件,避免緩存命中減少收費輸入 token 後誤判檔位。", "Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "每個階梯最多支援 2 個條件。最後一個無條件階梯作為兜底。", - "Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.", + "Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "用戶透過您的推薦連結註冊後,您即可獲得獎勵。可隨時將累計獎勵轉入餘額。", "Edit": "編輯", "Edit {{title}}": "編輯{{title}}", "Edit all channels with tag:": "編輯所有帶有標籤的渠道:", @@ -2013,6 +2013,8 @@ "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 Stream": "強制串流", + "Force streaming upstream and buffer the response as non-streaming JSON. Useful for upstream providers that only support streaming. Incompatible with Pass Through Body.": "強制上游使用串流請求,並將回應緩衝為非串流 JSON 回傳。適用於僅支援串流的上游。與請求體透傳不相容。", "Force-disabled two-factor authentication for the user": "強制關閉了用戶的兩步驗證", "Forest Whisper": "森林低語", "Forgot password": "忘記密碼", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 92f52151eefb..8cabe1346c09 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2013,6 +2013,8 @@ "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 Stream": "强制流式", + "Force streaming upstream and buffer the response as non-streaming JSON. Useful for upstream providers that only support streaming. Incompatible with Pass Through Body.": "强制上游使用流式请求,并将响应缓冲为非流式 JSON 返回。适用于仅支持流式的上游。与请求体透传不兼容。", "Force-disabled two-factor authentication for the user": "强制关闭了用户的两步验证", "Forest Whisper": "森林低语", "Forgot password": "忘记密码",