From d9595831bf05b59198742dca7e779bbea3165141 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 10 Jul 2026 22:30:38 +0800 Subject: [PATCH] fix(billing): improve quota handling and error reporting for pre-consume operations --- common/quota_math.go | 57 +++++++++--- common/quota_math_test.go | 18 ++++ dto/openai_image.go | 13 ++- pkg/billingexpr/round.go | 8 +- relay/helper/openai_image_request_test.go | 11 +++ relay/helper/price.go | 50 ++++++----- relay/helper/price_test.go | 102 +++++++++++++++++++++- relay/image_handler.go | 10 --- service/billing.go | 3 +- service/quota_saturation_test.go | 4 + service/text_quota_test.go | 28 ++++++ types/request_meta.go | 3 +- 12 files changed, 246 insertions(+), 61 deletions(-) diff --git a/common/quota_math.go b/common/quota_math.go index 1a5a5ac7..86c72492 100644 --- a/common/quota_math.go +++ b/common/quota_math.go @@ -16,11 +16,14 @@ const ( MinQuota = math.MinInt32 ) +// QuotaClampKind identifies why a quota conversion had to be saturated. +type QuotaClampKind string + // Clamp kinds reported by QuotaClamp.Kind. const ( - QuotaClampOverflow = "overflow" - QuotaClampUnderflow = "underflow" - QuotaClampNaN = "nan" + QuotaClampOverflow QuotaClampKind = "overflow" + QuotaClampUnderflow QuotaClampKind = "underflow" + QuotaClampNaN QuotaClampKind = "nan" ) // QuotaClamp describes a single saturation event: a quota conversion whose @@ -28,10 +31,19 @@ const ( // therefore clamped. It is surfaced to billing callers so the event can be // recorded on the related consume/task log for admin auditing. type QuotaClamp struct { - Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal" - Kind string `json:"kind"` // "overflow" | "underflow" | "nan" - Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx) - Clamped int `json:"clamped"` // the saturated result actually used + Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal" + Kind QuotaClampKind `json:"kind"` // "overflow" | "underflow" | "nan" + Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx) + Clamped int `json:"clamped"` // the saturated result actually used +} + +// Error lets the same typed value serve both as the settlement audit marker +// and as the fail-fast error returned by strict pre-consume conversions. +func (c *QuotaClamp) Error() string { + if c == nil { + return "" + } + return fmt.Sprintf("quota conversion (%s) %s: original=%g, clamped=%d", c.Op, c.Kind, c.Original, c.Clamped) } // AuditMap renders the clamp as the marker stored under a log's @@ -58,19 +70,26 @@ func (c *QuotaClamp) AuditMap() map[string]interface{} { // record the event (e.g. on the consume log); the returned pointer is nil for // in-range values. func saturateQuota(value float64, op string) (int, *QuotaClamp) { + var clamp *QuotaClamp switch { case math.IsNaN(value): - SysError(fmt.Sprintf("quota conversion (%s) received NaN, falling back to 0", op)) - return 0, &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0} + clamp = &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0} case value >= MaxQuota: - SysError(fmt.Sprintf("quota conversion (%s) overflow: %g exceeds max quota, clamped to %d", op, value, MaxQuota)) - return MaxQuota, &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota} + clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota} case value <= MinQuota: - SysError(fmt.Sprintf("quota conversion (%s) underflow: %g below min quota, clamped to %d", op, value, MinQuota)) - return MinQuota, &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota} + clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota} default: return int(value), nil } + SysError(clamp.Error()) + return clamp.Clamped, clamp +} + +func strictQuota(quota int, clamp *QuotaClamp) (int, error) { + if clamp != nil { + return 0, clamp + } + return quota, nil } // QuotaFromFloat converts a computed quota value to int, truncating toward @@ -87,6 +106,12 @@ func QuotaFromFloatChecked(value float64) (int, *QuotaClamp) { return saturateQuota(value, "QuotaFromFloat") } +// QuotaFromFloatStrict converts an in-range value and returns a typed +// *QuotaClamp error instead of allowing a saturated result to reach billing. +func QuotaFromFloatStrict(value float64) (int, error) { + return strictQuota(QuotaFromFloatChecked(value)) +} + // QuotaRound converts a float64 quota value to int using half-away-from-zero // rounding, with saturation. Every tiered billing path (pre-consume, // settlement, breakdown validation, log fields) MUST use this to avoid +-1 @@ -102,6 +127,12 @@ func QuotaRoundChecked(value float64) (int, *QuotaClamp) { return saturateQuota(math.Round(value), "QuotaRound") } +// QuotaRoundStrict rounds an in-range value and returns a typed *QuotaClamp +// error instead of allowing a saturated result to reach billing. +func QuotaRoundStrict(value float64) (int, error) { + return strictQuota(QuotaRoundChecked(value)) +} + // QuotaFromDecimal converts a computed quota decimal to int with saturation. // The decimal is rounded (half away from zero) before conversion. func QuotaFromDecimal(d decimal.Decimal) int { diff --git a/common/quota_math_test.go b/common/quota_math_test.go index 63a1de29..2d8742e6 100644 --- a/common/quota_math_test.go +++ b/common/quota_math_test.go @@ -6,6 +6,7 @@ import ( "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // 2000 quota per call * n=18446744073686646784 overflows int64; the constant @@ -78,6 +79,23 @@ func TestQuotaFromFloatChecked(t *testing.T) { } } +func TestQuotaFromFloatStrictReturnsTypedClampError(t *testing.T) { + quota, err := QuotaFromFloatStrict(42.9) + require.NoError(t, err) + assert.Equal(t, 42, quota) + + quota, err = QuotaFromFloatStrict(overflowingProduct) + assert.Zero(t, quota) + var clamp *QuotaClamp + require.ErrorAs(t, err, &clamp) + assert.Equal(t, QuotaClampOverflow, clamp.Kind) + assert.Equal(t, MaxQuota, clamp.Clamped) + assert.ErrorContains(t, err, "QuotaFromFloat") + assert.ErrorContains(t, err, "overflow") + assert.ErrorContains(t, err, "original=") + assert.ErrorContains(t, err, "clamped=2147483647") +} + // TestQuotaRoundChecked verifies the rounding entry point reports clamps the // same way. func TestQuotaRoundChecked(t *testing.T) { diff --git a/dto/openai_image.go b/dto/openai_image.go index ef99c1c7..275fd555 100644 --- a/dto/openai_image.go +++ b/dto/openai_image.go @@ -155,14 +155,19 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta { } } - // n is NOT included here; it is handled via OtherRatio("n") in - // image_handler.go (default) or channel adaptors (actual count). - // Including n here caused double-counting for channels that also - // set OtherRatio("n") (e.g. Ali/Bailian). + imageN := uint(1) + if i.N != nil && *i.N > 0 { + imageN = *i.N + } + + // Keep n separate from ImagePriceRatio so size/quality and count remain + // independent billing dimensions. Fixed-price pre-consume stores this on + // PriceData, and image settlement reuses or replaces the same "n" ratio. return &types.TokenCountMeta{ CombineText: i.Prompt, MaxTokens: 1584, ImagePriceRatio: sizeRatio * qualityRatio, + BillingRatios: map[string]float64{"n": float64(imageN)}, } } diff --git a/pkg/billingexpr/round.go b/pkg/billingexpr/round.go index 6d7438d8..80d7d318 100644 --- a/pkg/billingexpr/round.go +++ b/pkg/billingexpr/round.go @@ -13,9 +13,7 @@ func QuotaRound(f float64) int { return common.QuotaRound(f) } -// QuotaRoundChecked is QuotaRound but also reports whether the result had to -// be saturated. Pre-consume callers use this to reject an unrepresentable -// estimate before any quota is deducted. -func QuotaRoundChecked(f float64) (int, *common.QuotaClamp) { - return common.QuotaRoundChecked(f) +// QuotaRoundStrict rejects an unrepresentable pre-consume estimate. +func QuotaRoundStrict(f float64) (int, error) { + return common.QuotaRoundStrict(f) } diff --git a/relay/helper/openai_image_request_test.go b/relay/helper/openai_image_request_test.go index 7068b895..e9fb1b99 100644 --- a/relay/helper/openai_image_request_test.go +++ b/relay/helper/openai_image_request_test.go @@ -108,6 +108,16 @@ func TestGetAndValidOpenAIImageRequestNBounds(t *testing.T) { body: fmt.Sprintf(`{"model":"gpt-image-1","prompt":"a cat","n":%d}`, dto.MaxImageN), wantN: dto.MaxImageN, }, + { + name: "explicit n is accepted", + body: `{"model":"gpt-image-1","prompt":"a cat","n":3}`, + wantN: 3, + }, + { + name: "zero n defaults to 1", + body: `{"model":"gpt-image-1","prompt":"a cat","n":0}`, + wantN: 1, + }, { name: "absent n defaults to 1", body: `{"model":"gpt-image-1","prompt":"a cat"}`, @@ -127,6 +137,7 @@ func TestGetAndValidOpenAIImageRequestNBounds(t *testing.T) { require.NoError(t, err) require.NotNil(t, req.N) require.Equal(t, tt.wantN, *req.N) + require.Equal(t, float64(tt.wantN), req.GetTokenCountMeta().BillingRatios["n"]) }) } diff --git a/relay/helper/price.go b/relay/helper/price.go index 60fa043e..2e8ebb2d 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -32,10 +32,6 @@ func modelPriceNotConfiguredError(modelName string, userId int) error { ) } -func preConsumeQuotaRangeError(modelName string, clamp *common.QuotaClamp) error { - return fmt.Errorf("model %s pre-consume quota is out of range: operation=%s kind=%s value=%g", modelName, clamp.Op, clamp.Kind, clamp.Original) -} - // https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration const claudeCacheCreation1hMultiplier = 6 / 3.75 @@ -121,20 +117,15 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) ratio := modelRatio * groupRatioInfo.GroupRatio - var clamp *common.QuotaClamp - preConsumedQuota, clamp = common.QuotaFromFloatChecked(float64(preConsumedTokens) * ratio) - if clamp != nil { - return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp) + quota, err := common.QuotaFromFloatStrict(float64(preConsumedTokens) * ratio) + if err != nil { + return types.PriceData{}, err } + preConsumedQuota = quota } else { if meta.ImagePriceRatio != 0 { modelPrice = modelPrice * meta.ImagePriceRatio } - var clamp *common.QuotaClamp - preConsumedQuota, clamp = common.QuotaFromFloatChecked(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) - if clamp != nil { - return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp) - } } // check if free model pre-consume is disabled @@ -172,6 +163,17 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens CacheCreation1hRatio: cacheCreationRatio1h, QuotaToPreConsume: preConsumedQuota, } + if usePrice { + for name, ratio := range meta.BillingRatios { + priceData.AddOtherRatio(name, ratio) + } + quotaToPreConsume := priceData.ApplyOtherRatiosToFloat(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + quota, err := common.QuotaFromFloatStrict(quotaToPreConsume) + if err != nil { + return types.PriceData{}, err + } + priceData.QuotaToPreConsume = quota + } if common.DebugEnabled { logger.LogDebug(c, "model_price_helper result: %s", priceData.ToSetting()) @@ -211,10 +213,10 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types freeModel := false if usePrice { - var clamp *common.QuotaClamp - quota, clamp = common.QuotaFromFloatChecked(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) - if clamp != nil { - return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp) + var err error + quota, err = common.QuotaFromFloatStrict(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + if err != nil { + return types.PriceData{}, err } if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { @@ -224,10 +226,10 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } } else { // 按量计费:以模型倍率的一半作为预扣额度 - var clamp *common.QuotaClamp - quota, clamp = common.QuotaFromFloatChecked(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio) - if clamp != nil { - return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp) + var err error + quota, err = common.QuotaFromFloatStrict(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + if err != nil { + return types.PriceData{}, err } modelPrice = -1 if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { @@ -290,9 +292,9 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT // Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does. quotaBeforeGroup := rawCost / 1_000_000 * common.QuotaPerUnit - preConsumedQuota, clamp := billingexpr.QuotaRoundChecked(quotaBeforeGroup * groupRatioInfo.GroupRatio) - if clamp != nil { - return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp) + preConsumedQuota, err := billingexpr.QuotaRoundStrict(quotaBeforeGroup * groupRatioInfo.GroupRatio) + if err != nil { + return types.PriceData{}, err } freeModel := false diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go index c1a5416c..ef396146 100644 --- a/relay/helper/price_test.go +++ b/relay/helper/price_test.go @@ -10,6 +10,7 @@ import ( relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/billing_setting" "github.com/QuantumNous/new-api/setting/config" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -52,7 +53,9 @@ func TestModelPriceHelperTieredUsesPreloadedRequestInput(t *testing.T) { }, } - priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{}) + priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{ + BillingRatios: map[string]float64{"n": 3}, + }) require.NoError(t, err) require.Equal(t, 1500, priceData.QuotaToPreConsume) require.NotNil(t, info.TieredBillingSnapshot) @@ -172,5 +175,100 @@ func TestModelPriceHelperTieredRejectsPreConsumeOverflow(t *testing.T) { _, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{}) - require.ErrorContains(t, err, "pre-consume quota is out of range") + var clamp *common.QuotaClamp + require.ErrorAs(t, err, &clamp) + require.Equal(t, "QuotaRound", clamp.Op) + require.Equal(t, common.QuotaClampOverflow, clamp.Kind) +} + +func TestModelPriceHelperRequestBillingRatiosOnlyApplyToFixedPrice(t *testing.T) { + gin.SetMode(gin.TestMode) + savedModelPrices := ratio_setting.ModelPrice2JSONString() + savedModelRatios := ratio_setting.ModelRatio2JSONString() + t.Cleanup(func() { + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(savedModelPrices)) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedModelRatios)) + }) + + modelPrices, err := common.Marshal(map[string]float64{ + "fixed-image-price": 0.04, + "fractional-image-price": 0.0000012, + "overflow-image-price": float64(common.MaxQuota) / common.QuotaPerUnit / 2, + }) + require.NoError(t, err) + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(string(modelPrices))) + modelRatios, err := common.Marshal(map[string]float64{"ratio-image-price": 15}) + require.NoError(t, err) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(modelRatios))) + + tests := []struct { + name string + model string + wantQuota int + wantUsePrice bool + wantImageCount bool + }{ + { + name: "fixed price applies image count", + model: "fixed-image-price", + wantQuota: 180000, + wantUsePrice: true, + wantImageCount: true, + }, + { + name: "ratio price ignores request billing ratios", + model: "ratio-image-price", + wantQuota: 15000, + wantUsePrice: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Set("group", "default") + info := &relaycommon.RelayInfo{ + OriginModelName: tt.model, + UserGroup: "default", + UsingGroup: "default", + } + meta := &types.TokenCountMeta{ + ImagePriceRatio: 3, + BillingRatios: map[string]float64{"n": 3}, + } + + priceData, err := ModelPriceHelper(ctx, info, 1000, meta) + + require.NoError(t, err) + require.Equal(t, tt.wantQuota, priceData.QuotaToPreConsume) + require.Equal(t, tt.wantUsePrice, priceData.UsePrice) + require.Equal(t, tt.wantImageCount, priceData.HasOtherRatio("n")) + require.Equal(t, priceData.OtherRatios(), info.PriceData.OtherRatios()) + }) + } + + newInfo := func(model string) (*gin.Context, *relaycommon.RelayInfo) { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Set("group", "default") + return ctx, &relaycommon.RelayInfo{ + OriginModelName: model, + UserGroup: "default", + UsingGroup: "default", + } + } + meta := &types.TokenCountMeta{BillingRatios: map[string]float64{"n": 3}} + + ctx, info := newInfo("fractional-image-price") + priceData, err := ModelPriceHelper(ctx, info, 0, meta) + require.NoError(t, err) + // 0.0000012 * 500000 * 3 = 1.8, then truncate once to 1. + require.Equal(t, 1, priceData.QuotaToPreConsume) + + ctx, info = newInfo("overflow-image-price") + _, err = ModelPriceHelper(ctx, info, 0, meta) + var clamp *common.QuotaClamp + require.ErrorAs(t, err, &clamp) + require.Equal(t, "QuotaFromFloat", clamp.Op) + require.Equal(t, common.QuotaClampOverflow, clamp.Kind) + require.Nil(t, info.Billing) } diff --git a/relay/image_handler.go b/relay/image_handler.go index 52aa3e78..0c21fabc 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -123,16 +123,6 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type imageN = *request.N } - // n is handled via OtherRatio so it is applied exactly once in quota - // calculation (both price-based and ratio-based paths). - // Adaptors may have already set a more accurate count from the - // upstream response; only set the default when they haven't. - if info.PriceData.UsePrice { // only price model use N ratio - if !info.PriceData.HasOtherRatio("n") { - info.PriceData.AddOtherRatio("n", float64(imageN)) - } - } - if usage.(*dto.Usage).TotalTokens == 0 { usage.(*dto.Usage).TotalTokens = 1 } diff --git a/service/billing.go b/service/billing.go index 42cb68d3..02a093b6 100644 --- a/service/billing.go +++ b/service/billing.go @@ -19,9 +19,8 @@ const ( // 会话存储在 relayInfo.Billing 上,供后续 Settle / Refund 使用。 func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError { if relayInfo != nil && relayInfo.QuotaClamp != nil { - clamp := relayInfo.QuotaClamp return types.NewErrorWithStatusCode( - fmt.Errorf("pre-consume quota is out of range: operation=%s kind=%s value=%g", clamp.Op, clamp.Kind, clamp.Original), + relayInfo.QuotaClamp, types.ErrorCodeModelPriceError, http.StatusBadRequest, types.ErrOptionWithSkipRetry(), diff --git a/service/quota_saturation_test.go b/service/quota_saturation_test.go index bee96828..518bdde5 100644 --- a/service/quota_saturation_test.go +++ b/service/quota_saturation_test.go @@ -92,6 +92,10 @@ func TestPreConsumeBillingRejectsSaturatedQuotaBeforeDeduction(t *testing.T) { require.NotNil(t, apiErr) require.Equal(t, types.ErrorCodeModelPriceError, apiErr.GetErrorCode()) require.Equal(t, http.StatusBadRequest, apiErr.StatusCode) + require.Same(t, info.QuotaClamp, apiErr.Err) + var clamp *common.QuotaClamp + require.ErrorAs(t, apiErr, &clamp) + require.Same(t, info.QuotaClamp, clamp) require.Nil(t, info.Billing) } diff --git a/service/text_quota_test.go b/service/text_quota_test.go index c18fc47e..3b2bcb91 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -490,3 +490,31 @@ func TestTryTieredSettleNoClampInRange(t *testing.T) { require.NotNil(t, result) require.Nil(t, relayInfo.QuotaClamp, "in-range settlement must not record a clamp") } + +func TestCalculateTextQuotaSummaryFixedPriceAppliesImageCountOnceAndAllowsOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + priceData := types.PriceData{ + ModelPrice: 0.12, + UsePrice: true, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: 1, + }, + } + priceData.AddOtherRatio("n", 3) + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "dall-e-3", + PriceData: priceData, + StartTime: time.Now(), + } + usage := &dto.Usage{PromptTokens: 1, TotalTokens: 1} + + summary := calculateTextQuotaSummary(ctx, relayInfo, usage) + require.Equal(t, 180000, summary.Quota) + + // An adaptor-reported actual count replaces the requested count rather + // than multiplying it a second time. + relayInfo.PriceData.AddOtherRatio("n", 2) + summary = calculateTextQuotaSummary(ctx, relayInfo, usage) + require.Equal(t, 120000, summary.Quota) +} diff --git a/types/request_meta.go b/types/request_meta.go index 476ea052..10f609b4 100644 --- a/types/request_meta.go +++ b/types/request_meta.go @@ -26,7 +26,8 @@ type TokenCountMeta struct { Files []*FileMeta `json:"files,omitempty"` // List of files, each with type and content MaxTokens int `json:"max_tokens,omitempty"` // Maximum tokens allowed in the request - ImagePriceRatio float64 `json:"image_ratio,omitempty"` // Ratio for image size, if applicable + ImagePriceRatio float64 `json:"image_ratio,omitempty"` // Ratio for image size, if applicable + BillingRatios map[string]float64 `json:"billing_ratios,omitempty"` // Validated request multipliers used by pre-consume billing //IsStreaming bool `json:"is_streaming,omitempty"` // Indicates if the request is streaming }