feat: sync deployed v1.0.0-rc.38-custom.42

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+38
View File
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -33,6 +34,39 @@ func ResolveIncomingBillingExprRequestInput(c *gin.Context, info *relaycommon.Re
return input, nil
}
// ResolveImageBillingRequestInput freezes only the validated scalar image
// parameters needed by pricing. Image files, prompts and base64 payloads are
// deliberately excluded, including for multipart edits.
func ResolveImageBillingRequestInput(c *gin.Context, info *relaycommon.RelayInfo, input billingexpr.RequestInput) (billingexpr.RequestInput, error) {
request, ok := info.Request.(*dto.ImageRequest)
if !ok {
return input, nil
}
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
if info.ChannelMeta != nil {
channelType = info.ChannelType
}
count, err := request.ImageCount(channelType == constant.ChannelTypeAli)
if err != nil {
return input, err
}
topLevelCount, err := request.ImageCount(false)
if err != nil {
return input, err
}
body := map[string]any{"model": request.Model, "n": topLevelCount, "size": request.Size, "quality": request.Quality}
if request.BillingParameters != nil {
body["parameters"] = request.BillingParameters
}
encoded, err := common.Marshal(body)
if err != nil {
return input, err
}
input.Body = encoded
input.ImageCount = &count
return input, nil
}
func BuildBillingExprRequestInputFromRequest(request dto.Request, headers map[string]string) (billingexpr.RequestInput, error) {
input := billingexpr.RequestInput{
Headers: cloneStringMap(headers),
@@ -64,6 +98,10 @@ func cloneRequestInput(src billingexpr.RequestInput) billingexpr.RequestInput {
input := billingexpr.RequestInput{
Headers: cloneStringMap(src.Headers),
}
if src.ImageCount != nil {
count := *src.ImageCount
input.ImageCount = &count
}
if len(src.Body) > 0 {
input.Body = append([]byte(nil), src.Body...)
}
+22
View File
@@ -70,3 +70,25 @@ func TestMaxTokensBounds(t *testing.T) {
require.Contains(t, err.Error(), "max_output_tokens is invalid")
})
}
func TestSGLangMinTokensBounds(t *testing.T) {
for _, tc := range []struct {
value string
valid bool
}{
{"0", true}, {"8192", true}, {"-1", false}, {"18446744073686646784", false},
} {
t.Run(tc.value, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"served-model","messages":[{"role":"user","content":"hi"}],"min_tokens":`+tc.value+`}`))
c.Request.Header.Set("Content-Type", "application/json")
request, err := GetAndValidateTextRequest(c, relayconstant.RelayModeChatCompletions)
if !tc.valid {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, request.MinTokens)
})
}
}
+81
View File
@@ -11,12 +11,50 @@ import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLegacyDalleValidationAndPricesRemainCompatible(t *testing.T) {
for _, tc := range []struct {
body, size, quality string
ratio float64
invalid bool
}{
{body: `{"model":"dall-e-2"}`, size: "1024x1024", ratio: 1},
{body: `{"model":"dall-e"}`, size: "1024x1024", ratio: 1},
{body: `{"model":"dall-e-3"}`, size: "1024x1024", quality: "standard", ratio: 1},
{body: `{"model":"dall-e-2","size":"256x256"}`, size: "256x256", ratio: 0.4},
{body: `{"model":"dall-e-2","size":"512x512","quality":"hd"}`, size: "512x512", quality: "hd", ratio: 0.45},
{body: `{"model":"dall-e-3","quality":"hd"}`, size: "1024x1024", quality: "hd", ratio: 2},
{body: `{"model":"dall-e-3","size":"1024x1792","quality":"hd"}`, size: "1024x1792", quality: "hd", ratio: 3},
{body: `{"model":"dall-e-3","size":"1792x1024"}`, size: "1792x1024", quality: "standard", ratio: 2},
{body: `{"model":"dall-e-3","size":"256x256"}`, invalid: true},
{body: `{"model":"dall-e-2","size":"1024x1792"}`, invalid: true},
} {
t.Run(tc.body, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
request, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesGenerations)
if tc.invalid {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.size, request.Size)
assert.Equal(t, tc.quality, request.Quality)
assert.Equal(t, tc.ratio, request.GetTokenCountMeta().ImagePriceRatio)
})
}
}
// TestGetAndValidOpenAIImageRequestMultipartStream verifies multipart image
// edit parsing: the stream field is parsed and validated, and the request body
// stays replayable for the upstream request.
@@ -61,6 +99,11 @@ func TestGetAndValidOpenAIImageRequestMultipartStream(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "true", url.Values(form.Value).Get("stream"))
require.Len(t, form.File["image"], 1)
billing, err := ResolveImageBillingRequestInput(c, &relaycommon.RelayInfo{Request: req}, billingexpr.RequestInput{})
require.NoError(t, err)
require.Equal(t, 1, *billing.ImageCount)
require.NotContains(t, string(billing.Body), "fake image")
require.NotContains(t, string(billing.Body), "edit this image")
})
t.Run("invalid stream value is rejected", func(t *testing.T) {
@@ -72,6 +115,44 @@ func TestGetAndValidOpenAIImageRequestMultipartStream(t *testing.T) {
})
}
func TestImageBillingRequestUsesValidatedProviderCount(t *testing.T) {
for _, tc := range []struct {
body string
channel, count int
invalid bool
}{
{`{"model":"z-image","n":2,"parameters":{"n":3,"prompt_extend":true}}`, constant.ChannelTypeAli, 3, false},
{`{"model":"gpt-image-2","n":2,"parameters":{"n":3}}`, constant.ChannelTypeOpenAI, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":129}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","parameters":{"n":-1}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","n":2,"parameters":{}}`, constant.ChannelTypeAli, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":null}}`, constant.ChannelTypeAli, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":0}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","parameters":{"n":1.5}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","parameters":{"n":18446744073686646784}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","parameters":{"n":128}}`, constant.ChannelTypeAli, 128, false},
{`{"model":"z-image","n":0}`, constant.ChannelTypeAli, 1, false},
{`{"model":"gpt-image-2","n":2,"parameters":{"n":0}}`, constant.ChannelTypeOpenAI, 2, false},
} {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
common.SetContextKey(c, constant.ContextKeyChannelType, tc.channel)
request, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesGenerations)
if tc.invalid {
require.Error(t, err)
continue
}
require.NoError(t, err)
input, err := ResolveImageBillingRequestInput(c, &relaycommon.RelayInfo{Request: request}, billingexpr.RequestInput{})
require.NoError(t, err)
require.Equal(t, tc.count, *input.ImageCount)
cost, _, err := billingexpr.RunExprWithRequest(`tier("image", fixed(0.04)) * image_count`, billingexpr.TokenParams{}, input)
require.NoError(t, err)
require.Equal(t, float64(tc.count)*40000, cost)
}
}
// TestGetAndValidOpenAIImageRequestNBounds guards the billing invariant that
// the image generation count can never reach quota calculation with a value
// large enough to overflow int64 into a negative charge.
+52 -2
View File
@@ -5,10 +5,12 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/billing_setting"
@@ -132,10 +134,16 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return hosttypes.PriceData{}, err
}
preConsumedQuota = quota
if _, image := info.Request.(*dto.ImageRequest); image {
info.ImageQuotaBeforeGroup = float64(preConsumedTokens) * modelRatio
}
} else {
if meta.ImagePriceRatio != 0 {
modelPrice = modelPrice * meta.ImagePriceRatio
}
if _, image := info.Request.(*dto.ImageRequest); image {
info.ImageQuotaBeforeGroup = modelPrice * common.QuotaPerUnit
}
}
// check if free model pre-consume is disabled
@@ -177,6 +185,36 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
for name, ratio := range meta.BillingRatios {
priceData.AddOtherRatio(name, ratio)
}
}
if request, image := info.Request.(*dto.ImageRequest); image {
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
count, err := request.ImageCount(channelType == constant.ChannelTypeAli)
if err != nil {
return hosttypes.PriceData{}, err
}
if usePrice || channelType == constant.ChannelTypeAli {
priceData.AddOtherRatio("n", float64(count))
}
if channelType == constant.ChannelTypeAli && request.BillingParameters != nil && request.BillingParameters.PromptExtend != nil && *request.BillingParameters.PromptExtend {
// Resolve only routing identity; do not initialize ChannelMeta on the
// real request, which also distinguishes the first channel attempt.
mapped := &relaycommon.RelayInfo{OriginModelName: info.OriginModelName, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: info.OriginModelName}}
if err := ModelMappedHelper(c, mapped, nil); err != nil {
return hosttypes.PriceData{}, err
}
if strings.Contains(mapped.UpstreamModelName, "z-image") {
priceData.AddOtherRatio("prompt_extend", common.ZImagePromptExtendMultiplier)
}
}
if !usePrice {
quota, err := common.QuotaFromFloatStrict(priceData.ApplyOtherRatiosToFloat(info.ImageQuotaBeforeGroup * groupRatioInfo.GroupRatio))
if err != nil {
return hosttypes.PriceData{}, err
}
priceData.QuotaToPreConsume = quota
}
}
if usePrice {
quotaToPreConsume := priceData.ApplyOtherRatiosToFloat(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
quota, err := common.QuotaFromFloatStrict(quotaToPreConsume)
if err != nil {
@@ -324,6 +362,10 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billing
if !ok {
return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", billingModelName)
}
exprHash := billingexpr.ExprHashString(exprStr)
if info.RelayFormat == types.RelayFormatOpenAIRealtime && billingexpr.UsesFixedPricingByHash(exprStr, exprHash) {
return hosttypes.PriceData{}, fmt.Errorf("fixed pricing is not supported for Realtime requests")
}
estimatedCompletionTokens := meta.MaxTokens
if estimatedCompletionTokens == 0 && groupRatioInfo.GroupRatio != 0 {
@@ -334,8 +376,14 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billing
if err != nil {
return hosttypes.PriceData{}, err
}
if billingexpr.UsedVarsByHash(exprStr, exprHash)["image_count"] {
requestInput, err = ResolveImageBillingRequestInput(c, info, requestInput)
if err != nil {
return hosttypes.PriceData{}, err
}
}
rawCost, trace, err := billingexpr.RunExprWithRequest(exprStr, billingexpr.TokenParams{
rawCost, trace, err := billingexpr.RunExprByHashWithRequest(exprStr, exprHash, billingexpr.TokenParams{
P: float64(promptTokens),
C: float64(estimatedCompletionTokens),
Len: float64(promptTokens),
@@ -359,8 +407,8 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billing
}
}
exprHash := billingexpr.ExprHashString(exprStr)
snapshot := &billingexpr.BillingSnapshot{
EstimatedImageCount: trace.ImageCount,
BillingMode: billing_setting.BillingModeTieredExpr,
ModelName: billingModelName,
ExprString: exprStr,
@@ -371,6 +419,8 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billing
EstimatedQuotaBeforeGroup: quotaBeforeGroup,
EstimatedQuotaAfterGroup: preConsumedQuota,
EstimatedTier: trace.MatchedTier,
EstimatedBillingUnit: trace.BillingUnit,
EstimatedFixedPrice: trace.FixedPrice,
QuotaPerUnit: common.QuotaPerUnit,
ExprVersion: billingexpr.ExprVersion(exprStr),
}
+41
View File
@@ -68,6 +68,47 @@ func TestModelPriceHelperTieredUsesPreloadedRequestInput(t *testing.T) {
require.Equal(t, common.QuotaPerUnit, info.TieredBillingSnapshot.QuotaPerUnit)
}
func TestFixedPricePreConsumeAndRealtimeRejection(t *testing.T) {
saved := map[string]string{}
require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
saved[key] = value
return nil
}))
t.Cleanup(func() { require.NoError(t, config.GlobalConfig.LoadFromDB(saved)) })
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
"billing_setting.billing_mode": `{"fixed-test":"tiered_expr"}`,
"billing_setting.billing_expr": `{"fixed-test":"len <= 32000 ? tier(\"short\", fixed(0.01)) : tier(\"long\", p * 2)"}`,
"group_ratio_setting.group_ratio": `{"default":1.5}`,
}))
for _, tc := range []struct {
name string
format types.RelayFormat
prompt int
wantError bool
}{
{"HTTP charges once", types.RelayFormatOpenAI, 0, false},
{"Realtime rejects even unselected fixed branch", types.RelayFormatOpenAIRealtime, 50000, true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
info := &relaycommon.RelayInfo{OriginModelName: "fixed-test", UserGroup: "default", UsingGroup: "default", RelayFormat: tc.format, BillingRequestInput: &billingexpr.RequestInput{}}
price, err := ModelPriceHelper(ctx, info, tc.prompt, &types.TokenCountMeta{})
if tc.wantError {
require.ErrorContains(t, err, "Realtime")
assert.Nil(t, info.TieredBillingSnapshot)
return
}
require.NoError(t, err)
assert.Equal(t, 7500, price.QuotaToPreConsume)
require.NotNil(t, info.TieredBillingSnapshot)
assert.Equal(t, billingexpr.BillingUnitRequest, info.TieredBillingSnapshot.EstimatedBillingUnit)
require.NotNil(t, info.TieredBillingSnapshot.EstimatedFixedPrice)
assert.Equal(t, 0.01, *info.TieredBillingSnapshot.EstimatedFixedPrice)
})
}
}
func TestModelPriceHelperTieredPreConsumeMaxTokensFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
+4 -3
View File
@@ -59,10 +59,11 @@ func ApplyReasoningModelSuffix(c *gin.Context, info *relaycommon.RelayInfo, outb
}
if selected.hasThinking {
explicit, err := explicitIntentFromRequest(info.Request)
explicit, explicitDiagnostics, err := explicitIntentFromRequest(info.Request)
if err != nil {
return reasoning.AsClientError(err)
}
diagnostics = append(diagnostics, explicitDiagnostics...)
diagnostics = append(diagnostics, modifierRequestOverrideDiagnostics(explicit, selected.intent)...)
if selected.intent.IncludeThoughts == nil {
selected.intent.IncludeThoughts = explicit.IncludeThoughts
@@ -215,7 +216,7 @@ func parseHostModelSuffix(name string, opts *convmeta.Options) (string, reasonin
)
}
func explicitIntentFromRequest(req dto.Request) (reasoning.Intent, error) {
func explicitIntentFromRequest(req dto.Request) (reasoning.Intent, []types.ConversionDiagnostic, error) {
switch r := req.(type) {
case *dto.ClaudeRequest:
return reasoning.FromClaude(r)
@@ -226,6 +227,6 @@ func explicitIntentFromRequest(req dto.Request) (reasoning.Intent, error) {
case *dto.OpenAIResponsesRequest:
return reasoning.FromOpenAIResponses(r)
default:
return reasoning.Intent{}, nil
return reasoning.Intent{}, nil, nil
}
}
+8 -3
View File
@@ -1,7 +1,10 @@
package helper
import (
"errors"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
)
// StreamResult is passed to each dataHandler invocation, providing methods
@@ -23,13 +26,15 @@ func (r *StreamResult) Error(err error) {
return
}
r.status.RecordError(err.Error())
var apiErr *types.NewAPIError
if errors.As(err, &apiErr) {
r.status.MarkFailed(string(apiErr.GetErrorCode()), apiErr.ToOpenAIError().Type, apiErr.StatusCode)
}
}
// Stop records a fatal error and marks the stream to stop after this chunk.
func (r *StreamResult) Stop(err error) {
if err != nil {
r.status.RecordError(err.Error())
}
r.Error(err)
r.status.SetEndReason(relaycommon.StreamEndReasonHandlerStop, err)
r.stopped = true
}
+46 -12
View File
@@ -30,7 +30,8 @@ const (
// unconditional wg.Wait() in cleanup can always finish. Without it, a slow
// but connected client (full TCP buffer, no server WriteTimeout) could hang
// the handler forever.
streamWriteTimeout = 30 * time.Second
streamWriteTimeout = 30 * time.Second
clientGoneDrainTimeout = 2 * time.Minute
)
func getScannerBufferSize() int {
@@ -40,9 +41,15 @@ func getScannerBufferSize() int {
return DefaultMaxScannerBufferSize
}
func NewStreamScanner(reader io.Reader) *bufio.Scanner {
// NewStreamScanner shares relay scanner configuration. Callers buffering bounded
// task state may additionally cap a line without increasing the configured limit.
func NewStreamScanner(reader io.Reader, maxBytes ...int) *bufio.Scanner {
limit := getScannerBufferSize()
if len(maxBytes) > 0 && maxBytes[0] > 0 {
limit = min(limit, maxBytes[0])
}
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize())
scanner.Buffer(make([]byte, min(InitialScannerBufferSize, limit)), limit)
return scanner
}
@@ -121,11 +128,11 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
logger.LogDebug(c, "streaming timeout seconds: %d", int64(streamingTimeout.Seconds()))
logger.LogDebug(c, "ping interval seconds: %d", int64(pingInterval.Seconds()))
cleanup := func() {
cleanup := func(closeUpstream bool) {
cleanupOnce.Do(func() {
cancel()
stop()
if resp.Body != nil {
if closeUpstream && resp.Body != nil {
_ = resp.Body.Close()
}
@@ -138,7 +145,9 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
})
}
// Ensure gin.Context is not returned to Gin's pool while any stream goroutine can still use it.
defer cleanup()
defer func() {
cleanup(true)
}()
scanner.Split(bufio.ScanLines)
copyCodexSSEHeaders(c, resp)
@@ -289,19 +298,44 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil)
})
// 客户端断开后仍读取上游终止事件中的 usage,避免上游已计费而
// NewAPI 无法结算。各写入函数会自行跳过已断开的客户端。
drainClientGone := func(err error) {
info.StreamStatus.SetClientGone(err)
logger.LogInfo(c, "client disconnected; draining upstream for billing usage")
drainTimer := time.NewTimer(clientGoneDrainTimeout)
select {
case <-stopChan:
case <-drainTimer.C:
logger.LogError(c, "client_gone upstream drain timeout; usage may be unavailable")
}
if !drainTimer.Stop() {
select {
case <-drainTimer.C:
default:
}
}
}
// 主循环等待完成或超时
select {
case <-ticker.C:
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonTimeout, nil)
if err := c.Request.Context().Err(); err != nil {
drainClientGone(err)
} else {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonTimeout, nil)
}
case <-stopChan:
// EndReason already set by the goroutine that triggered stopChan
// A client disconnect can race with upstream [DONE]. Preserve it as the
// diagnostic reason while keeping fatal handler/scanner reasons intact.
if err := c.Request.Context().Err(); err != nil {
info.StreamStatus.SetClientGone(err)
}
case <-c.Request.Context().Done():
// 客户端断开:立即 cleanup 关闭上游 resp.Body,解除 scanner 阻塞并让上游停止生成,
// 避免为已放弃的请求继续消费上游 token。
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, c.Request.Context().Err())
drainClientGone(c.Request.Context().Err())
}
cleanup()
cleanup(true)
if info.StreamStatus.IsNormalEnd() && !info.StreamStatus.HasErrors() {
logger.LogInfo(c, fmt.Sprintf("stream ended: %s", info.StreamStatus.Summary()))
} else {
+20 -22
View File
@@ -211,12 +211,9 @@ func TestStreamScannerHandler_DataWithExtraSpaces(t *testing.T) {
assert.Equal(t, "{\"trimmed\":true}", got)
}
// TestStreamScannerHandler_ClientCancelAbortsUpstreamAndReturns pins the
// disconnect contract: when the client goes away, the handler must return
// promptly (all goroutines joined, so the gin.Context can never leak into a
// pooled reuse), the upstream body must be closed to stop token generation,
// and no data received after the disconnect may be processed or written.
func TestStreamScannerHandler_ClientCancelAbortsUpstreamAndReturns(t *testing.T) {
// TestStreamScannerHandler_ClientCancelDrainsUpstreamUsage verifies that a client
// disconnect stops writes but leaves the upstream readable through its terminal event.
func TestStreamScannerHandler_ClientCancelDrainsUpstreamUsage(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -252,7 +249,6 @@ func TestStreamScannerHandler_ClientCancelAbortsUpstreamAndReturns(t *testing.T)
_, err := fmt.Fprint(pw, "data: first\n")
require.NoError(t, err)
select {
case <-firstHandled:
case <-time.After(2 * time.Second):
@@ -260,31 +256,21 @@ func TestStreamScannerHandler_ClientCancelAbortsUpstreamAndReturns(t *testing.T)
}
cancel()
_, err = fmt.Fprint(pw, "data: usage\ndata: [DONE]\n")
require.NoError(t, err, "client disconnect must not close the upstream body")
require.NoError(t, pw.Close())
// The handler must return without any further upstream input: cleanup
// closes resp.Body, which unblocks the scanner goroutine.
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("handler did not return after client disconnect")
t.Fatal("handler did not finish draining the upstream stream")
}
// Upstream read side must be closed so the provider stops generating
// (and billing) for a request nobody is listening to.
_, err = fmt.Fprint(pw, "data: second\n")
require.ErrorIs(t, err, io.ErrClosedPipe, "upstream body should be closed after client disconnect")
assert.Equal(t, int64(1), count.Load(), "no chunk after disconnect should be processed")
assert.Equal(t, int64(2), count.Load(), "terminal upstream data should be processed after client disconnect")
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonClientGone, info.StreamStatus.EndReason)
body := recorder.Body.String()
assert.Contains(t, body, "first")
assert.NotContains(t, body, "second")
}
// ---------- Ping tests ----------
func TestStreamScannerHandler_PingSentDuringSlowUpstream(t *testing.T) {
setting := operation_setting.GetGeneralSetting()
oldEnabled := setting.PingIntervalEnabled
@@ -571,3 +557,15 @@ func TestStreamScannerHandler_StreamStatus_ReplacesPreInitialized(t *testing.T)
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
assert.Equal(t, 0, info.StreamStatus.TotalErrorCount())
}
func TestNewStreamScannerCallerLimit(t *testing.T) {
// The smaller buffer must actually constrain a line; a preallocated 64 KiB
// buffer would otherwise bypass this caller's 1 KiB limit in bufio.Scanner.
scanner := NewStreamScanner(strings.NewReader(strings.Repeat("x", 2048)+"\n"), 1024)
assert.False(t, scanner.Scan())
require.Error(t, scanner.Err())
scanner = NewStreamScanner(strings.NewReader("data: ok\n"), 1024)
require.True(t, scanner.Scan())
assert.Equal(t, "data: ok", scanner.Text())
require.NoError(t, scanner.Err())
}
+31 -25
View File
@@ -1,6 +1,7 @@
package helper
import (
"encoding/json"
"errors"
"fmt"
"math"
@@ -9,6 +10,7 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -121,7 +123,8 @@ func GetAndValidateEmbeddingRequest(c *gin.Context, relayMode int) (*dto.Embeddi
// overflow the conversion and corrupt billing.
const maxTokensLimit = math.MaxInt32 / 2
func exceedsMaxTokensLimit(values ...*uint) bool {
// ExceedsMaxTokensLimit checks token limits before they reach billing arithmetic.
func ExceedsMaxTokensLimit(values ...*uint) bool {
for _, v := range values {
if lo.FromPtrOr(v, uint(0)) > maxTokensLimit {
return true
@@ -142,7 +145,7 @@ func GetAndValidateResponsesRequest(c *gin.Context) (*dto.OpenAIResponsesRequest
if request.Input == nil {
return nil, errors.New("input is required")
}
if exceedsMaxTokensLimit(request.MaxOutputTokens) {
if ExceedsMaxTokensLimit(request.MaxOutputTokens) {
return nil, errors.New("max_output_tokens is invalid")
}
return request, nil
@@ -203,6 +206,9 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
}
imageRequest.Quality = formData.Get("quality")
imageRequest.Size = formData.Get("size")
if parameters := formData.Get("parameters"); parameters != "" {
imageRequest.Extra = map[string]json.RawMessage{"parameters": json.RawMessage(parameters)}
}
if streamValue := strings.TrimSpace(formData.Get("stream")); streamValue != "" {
stream, err := strconv.ParseBool(streamValue)
if err != nil {
@@ -238,7 +244,6 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
}
if imageRequest.Model == "" {
//imageRequest.Model = "dall-e-3"
return nil, errors.New("model is required")
}
@@ -250,25 +255,10 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
return nil, fmt.Errorf("n must be an integer between 1 and %d", dto.MaxImageN)
}
// Not "256x256", "512x512", or "1024x1024"
if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" {
if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" {
return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024 for dall-e-2 or dall-e")
}
if imageRequest.Size == "" {
imageRequest.Size = "1024x1024"
}
} else if imageRequest.Model == "dall-e-3" {
if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" {
return nil, errors.New("size must be one of 1024x1024, 1024x1792 or 1792x1024 for dall-e-3")
}
if imageRequest.Quality == "" {
imageRequest.Quality = "standard"
}
if imageRequest.Size == "" {
imageRequest.Size = "1024x1024"
}
} else if imageRequest.Model == "gpt-image-1" {
if err := imageRequest.NormalizeLegacyDalleImageRequest(); err != nil {
return nil, err
}
if imageRequest.Model == "gpt-image-1" {
if imageRequest.Quality == "" {
imageRequest.Quality = "auto"
}
@@ -283,6 +273,19 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
}
}
// Provider parameters can override the top-level count. Validate before
// pricing so malformed multipliers return a client error, not a pricing
// failure after reservation has started.
if raw, exists := imageRequest.Extra["parameters"]; exists {
parameters := &dto.ImageBillingParameters{}
if err := common.Unmarshal(raw, parameters); err != nil {
return nil, fmt.Errorf("invalid image parameters: %w", err)
}
imageRequest.BillingParameters = parameters
}
if _, err := imageRequest.ImageCount(common.GetContextKeyInt(c, constant.ContextKeyChannelType) == constant.ChannelTypeAli); err != nil {
return nil, err
}
return imageRequest, nil
}
@@ -298,7 +301,7 @@ func GetAndValidateClaudeRequest(c *gin.Context) (textRequest *dto.ClaudeRequest
if textRequest.Model == "" {
return nil, errors.New("field model is required")
}
if exceedsMaxTokensLimit(textRequest.MaxTokens, textRequest.MaxTokensToSample) {
if ExceedsMaxTokensLimit(textRequest.MaxTokens, textRequest.MaxTokensToSample) {
return nil, errors.New("max_tokens is invalid")
}
@@ -323,9 +326,12 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA
textRequest.Model = c.Param("model")
}
if exceedsMaxTokensLimit(textRequest.MaxTokens, textRequest.MaxCompletionTokens) {
if ExceedsMaxTokensLimit(textRequest.MaxTokens, textRequest.MaxCompletionTokens) {
return nil, errors.New("max_tokens is invalid")
}
if ExceedsMaxTokensLimit(textRequest.MinTokens) {
return nil, errors.New("min_tokens is invalid")
}
if textRequest.Model == "" {
return nil, errors.New("model is required")
}
@@ -376,7 +382,7 @@ func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error)
if len(request.Contents) == 0 && len(request.Requests) == 0 {
return nil, errors.New("contents is required")
}
if exceedsMaxTokensLimit(request.GenerationConfig.MaxOutputTokens) {
if ExceedsMaxTokensLimit(request.GenerationConfig.MaxOutputTokens) {
return nil, errors.New("maxOutputTokens is invalid")
}