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:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user