Merge remote-tracking branch 'origin/main'
This commit is contained in:
+44
-13
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+9
-4
@@ -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)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,3 +12,8 @@ import "github.com/QuantumNous/new-api/common"
|
||||
func QuotaRound(f float64) int {
|
||||
return common.QuotaRound(f)
|
||||
}
|
||||
|
||||
// QuotaRoundStrict rejects an unrepresentable pre-consume estimate.
|
||||
func QuotaRoundStrict(f float64) (int, error) {
|
||||
return common.QuotaRoundStrict(f)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -32,6 +35,37 @@ func newImageTestContext(t *testing.T, body, contentType string, isStream bool)
|
||||
return c, recorder, resp, info
|
||||
}
|
||||
|
||||
func TestOpenaiImageDoResponseUsesInfoIsStream(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
body := `{"created":1710000000,"data":[{"b64_json":"image"}]}`
|
||||
|
||||
t.Run("non-stream response stays JSON", func(t *testing.T) {
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", false)
|
||||
info.RelayMode = relayconstant.RelayModeImagesGenerations
|
||||
|
||||
usage, err := (&Adaptor{}).DoResponse(c, resp, info)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, body, recorder.Body.String())
|
||||
})
|
||||
|
||||
t.Run("stream response converts JSON to SSE", func(t *testing.T) {
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", true)
|
||||
info.RelayMode = relayconstant.RelayModeImagesGenerations
|
||||
|
||||
usage, err := (&Adaptor{}).DoResponse(c, resp, info)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.Contains(t, recorder.Body.String(), `event: image_generation.completed`)
|
||||
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerForwardsSSEAndUsage covers the core SSE path:
|
||||
// chunks are forwarded with rebuilt event lines, usage is extracted and
|
||||
// normalized (input_tokens -> prompt_tokens with details), and [DONE] is
|
||||
@@ -56,6 +90,8 @@ func TestOpenaiImageStreamHandlerForwardsSSEAndUsage(t *testing.T) {
|
||||
}, "\n")
|
||||
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "text/event-stream", true)
|
||||
info.PriceData.UsePrice = true
|
||||
info.PriceData.AddOtherRatio("n", 3)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
@@ -69,6 +105,181 @@ func TestOpenaiImageStreamHandlerForwardsSSEAndUsage(t *testing.T) {
|
||||
require.Contains(t, recorder.Body.String(), `data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`)
|
||||
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
|
||||
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
|
||||
require.Equal(t, 3.0, info.PriceData.OtherRatios()["n"], "streams without completed events keep the requested count")
|
||||
}
|
||||
|
||||
func TestOpenaiImageStreamHandlerUsesCompletedEventCount(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
body := strings.Join([]string{
|
||||
`data: {"type":"image_generation.partial_image","partial_image_index":0,"b64_json":"partial"}`,
|
||||
``,
|
||||
`data: {"type":"image_generation.completed","b64_json":"first"}`,
|
||||
``,
|
||||
`data: {"type":"image_edit.completed","b64_json":"second","usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7}}`,
|
||||
``,
|
||||
`data: [DONE]`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
c, _, resp, info := newImageTestContext(t, body, "text/event-stream", true)
|
||||
info.PriceData.UsePrice = true
|
||||
info.PriceData.AddOtherRatio("n", 3)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 7, usage.TotalTokens)
|
||||
require.Equal(t, 2.0, info.PriceData.OtherRatios()["n"])
|
||||
}
|
||||
|
||||
// blockingBody serves one SSE chunk, then blocks until Close (the scanner's
|
||||
// cleanup) and returns EOF — keeping the upstream "open" while the client-side
|
||||
// disconnect is simulated elsewhere.
|
||||
type blockingBody struct {
|
||||
mu sync.Mutex
|
||||
sent bool
|
||||
chunk []byte
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (b *blockingBody) Read(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
if !b.sent {
|
||||
b.sent = true
|
||||
n := copy(p, b.chunk)
|
||||
b.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
b.mu.Unlock()
|
||||
<-b.closed
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (b *blockingBody) Close() error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
select {
|
||||
case <-b.closed:
|
||||
default:
|
||||
close(b.closed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelAfterWriter cancels the request context right after the payload
|
||||
// containing needle has been written to the client, simulating a client that
|
||||
// disconnects after receiving that event. Cancelling from the write side (not
|
||||
// the upstream read side) makes the abort deterministic: the handler has
|
||||
// already processed and counted the event when the disconnect fires.
|
||||
type cancelAfterWriter struct {
|
||||
gin.ResponseWriter
|
||||
needle string
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (w *cancelAfterWriter) Write(p []byte) (int, error) {
|
||||
n, err := w.ResponseWriter.Write(p)
|
||||
if strings.Contains(string(p), w.needle) {
|
||||
w.once.Do(w.cancel)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *cancelAfterWriter) WriteString(s string) (int, error) {
|
||||
n, err := io.WriteString(w.ResponseWriter, s)
|
||||
if strings.Contains(s, w.needle) {
|
||||
w.once.Do(w.cancel)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func newDisconnectingImageStream(t *testing.T, sseBody, disconnectAfter string) (*gin.Context, *httptest.ResponseRecorder, *http.Response, *relaycommon.RelayInfo) {
|
||||
t.Helper()
|
||||
c, recorder, resp, info := newImageTestContext(t, "", "text/event-stream", true)
|
||||
ctx, cancel := context.WithCancel(c.Request.Context())
|
||||
t.Cleanup(cancel)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Writer = &cancelAfterWriter{ResponseWriter: c.Writer, needle: disconnectAfter, cancel: cancel}
|
||||
resp.Body = &blockingBody{
|
||||
chunk: []byte(sseBody),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
return c, recorder, resp, info
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerClientDisconnectKeepsRequestedCount guards the
|
||||
// billing invariant: completed-event counting must not lower the charge when
|
||||
// the client aborts the stream. Upstream already generated (and charged for)
|
||||
// all requested images, so a disconnect after the first completed event keeps
|
||||
// the requested n instead of dropping it to 1.
|
||||
func TestOpenaiImageStreamHandlerClientDisconnectKeepsRequestedCount(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
body := "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"first\"}\n\n"
|
||||
c, recorder, resp, info := newDisconnectingImageStream(t, body, "first")
|
||||
info.PriceData.UsePrice = true
|
||||
info.PriceData.AddOtherRatio("n", 3)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
// A client abort surfaces as client_gone (main-loop ctx watch) or
|
||||
// handler_stop (failed client write); both must be treated as untrusted.
|
||||
require.Contains(t,
|
||||
[]relaycommon.StreamEndReason{relaycommon.StreamEndReasonClientGone, relaycommon.StreamEndReasonHandlerStop},
|
||||
info.StreamStatus.EndReason)
|
||||
require.Contains(t, recorder.Body.String(), `"b64_json":"first"`)
|
||||
require.Equal(t, 3.0, info.PriceData.OtherRatios()["n"], "client abort must not reduce the billed image count")
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerClientDisconnectRaisesCount covers the other
|
||||
// direction of the abort guard: when completed events already exceed the
|
||||
// recorded n, the higher actual count is billed even though the client aborted.
|
||||
func TestOpenaiImageStreamHandlerClientDisconnectRaisesCount(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
body := strings.Join([]string{
|
||||
`data: {"type":"image_generation.completed","b64_json":"first"}`,
|
||||
``,
|
||||
`data: {"type":"image_generation.completed","b64_json":"second"}`,
|
||||
``,
|
||||
``,
|
||||
}, "\n")
|
||||
c, _, resp, info := newDisconnectingImageStream(t, body, "second")
|
||||
info.PriceData.UsePrice = true
|
||||
info.PriceData.AddOtherRatio("n", 1)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
require.Contains(t,
|
||||
[]relaycommon.StreamEndReason{relaycommon.StreamEndReasonClientGone, relaycommon.StreamEndReasonHandlerStop},
|
||||
info.StreamStatus.EndReason)
|
||||
require.Equal(t, 2.0, info.PriceData.OtherRatios()["n"], "completed events beyond the recorded n must raise the charge even on abort")
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerWrapsJSONResponse covers the non-SSE fallback:
|
||||
@@ -78,9 +289,11 @@ func TestOpenaiImageStreamHandlerWrapsJSONResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
body := `{"created":1710000000,"data":[{"b64_json":"final","revised_prompt":"draw a cat"}],"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`
|
||||
body := `{"created":1710000000,"data":[{"b64_json":"first","revised_prompt":"draw a cat"},{"b64_json":"second"}],"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`
|
||||
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", true)
|
||||
info.PriceData.UsePrice = true
|
||||
info.PriceData.AddOtherRatio("n", 3)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
@@ -93,9 +306,59 @@ func TestOpenaiImageStreamHandlerWrapsJSONResponse(t *testing.T) {
|
||||
require.Empty(t, recorder.Header().Get("Content-Length"))
|
||||
require.Contains(t, recorder.Body.String(), `event: image_generation.completed`)
|
||||
require.Contains(t, recorder.Body.String(), `"type":"image_generation.completed"`)
|
||||
require.Contains(t, recorder.Body.String(), `"b64_json":"final"`)
|
||||
require.Contains(t, recorder.Body.String(), `"b64_json":"first"`)
|
||||
require.Contains(t, recorder.Body.String(), `"b64_json":"second"`)
|
||||
require.Contains(t, recorder.Body.String(), `"revised_prompt":"draw a cat"`)
|
||||
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
|
||||
require.Equal(t, 2, strings.Count(recorder.Body.String(), `event: image_generation.completed`))
|
||||
require.Equal(t, 2.0, info.PriceData.OtherRatios()["n"])
|
||||
}
|
||||
|
||||
func TestOpenaiImageHandlerUsesPositiveActualCountForFixedPrice(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
longImage := strings.Repeat("a", 4096)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
usePrice bool
|
||||
wantCount float64
|
||||
}{
|
||||
{
|
||||
name: "fixed price uses data length",
|
||||
body: `{"data":[{"b64_json":"` + longImage + `"},{"b64_json":"second"}]}`,
|
||||
usePrice: true,
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
name: "empty data keeps requested count",
|
||||
body: `{"data":[]}`,
|
||||
usePrice: true,
|
||||
wantCount: 3,
|
||||
},
|
||||
{
|
||||
name: "ratio billing ignores data length",
|
||||
body: `{"data":[{"b64_json":"first"},{"b64_json":"second"}]}`,
|
||||
usePrice: false,
|
||||
wantCount: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c, recorder, resp, info := newImageTestContext(t, tt.body, "application/json", false)
|
||||
info.PriceData.UsePrice = tt.usePrice
|
||||
info.PriceData.AddOtherRatio("n", 3)
|
||||
|
||||
_, err := OpenaiImageHandler(c, info, resp)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, tt.wantCount, info.PriceData.OtherRatios()["n"])
|
||||
require.Equal(t, tt.body, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenaiImageHandlersReturnJSONError covers JSON error responses for both
|
||||
@@ -132,6 +395,19 @@ func TestOpenaiImageHandlersReturnJSONError(t *testing.T) {
|
||||
require.Equal(t, "content moderation failed", err.ToOpenAIError().Message)
|
||||
require.Empty(t, recorder.Body.String())
|
||||
})
|
||||
|
||||
t.Run("stream handler non-2xx stays JSON error", func(t *testing.T) {
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", true)
|
||||
resp.StatusCode = http.StatusBadGateway
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, usage)
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, http.StatusBadGateway, err.StatusCode)
|
||||
require.Equal(t, "content moderation failed", err.ToOpenAIError().Message)
|
||||
require.Empty(t, recorder.Body.String())
|
||||
require.NotContains(t, recorder.Header().Get("Content-Type"), "text/event-stream")
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerRecordsUpstreamErrorEvent verifies that an error
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,8 +18,17 @@ import (
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
func updateOpenAIImageCount(info *relaycommon.RelayInfo, count int64) {
|
||||
if info == nil || !info.PriceData.UsePrice || count <= 0 || count > int64(dto.MaxImageN) {
|
||||
return
|
||||
}
|
||||
info.PriceData.AddOtherRatio("n", float64(count))
|
||||
}
|
||||
|
||||
// OpenaiImageHandler handles non-streaming OpenAI image responses
|
||||
// (generations/edits), returning the parsed usage for billing.
|
||||
func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
@@ -39,6 +49,8 @@ func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
|
||||
updateOpenAIImageCount(info, gjson.GetBytes(responseBody, "data.#").Int())
|
||||
|
||||
// 写入新的 response body
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
@@ -88,7 +100,7 @@ func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
return OpenaiImageHandler(c, info, resp)
|
||||
}
|
||||
if !strings.Contains(contentType, "text/event-stream") {
|
||||
return OpenaiImageJSONAsStreamHandler(c, info, resp)
|
||||
return openaiImageJSONAsStreamHandler(c, info, resp)
|
||||
}
|
||||
// Reuse the shared streaming engine (helper.StreamScannerHandler) so the
|
||||
// image streaming path gets the same ping keepalive, streaming-timeout
|
||||
@@ -98,6 +110,7 @@ func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
// field (real OpenAI image events keep event == type).
|
||||
usage := &dto.Usage{}
|
||||
var lastStreamData []byte
|
||||
var completedImages int64
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||
raw := common.StringToByteSlice(data)
|
||||
@@ -107,39 +120,64 @@ func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
// EndReason. HasErrors() flags the failure for logging/handling.
|
||||
sr.Error(fmt.Errorf("%s", extractOpenAIImageStreamErrorMessage(raw)))
|
||||
}
|
||||
var usageResp dto.SimpleResponse
|
||||
if err := common.Unmarshal(raw, &usageResp); err == nil {
|
||||
normalizeOpenAIUsage(&usageResp.Usage)
|
||||
if service.ValidUsage(&usageResp.Usage) {
|
||||
usage = &usageResp.Usage
|
||||
var chunk struct {
|
||||
Type string `json:"type"`
|
||||
Usage dto.Usage `json:"usage"`
|
||||
}
|
||||
if err := common.Unmarshal(raw, &chunk); err == nil {
|
||||
normalizeOpenAIUsage(&chunk.Usage)
|
||||
if service.ValidUsage(&chunk.Usage) {
|
||||
usage = &chunk.Usage
|
||||
}
|
||||
if chunk.Type == "image_generation.completed" || chunk.Type == "image_edit.completed" {
|
||||
completedImages++
|
||||
}
|
||||
}
|
||||
writeOpenaiImageStreamChunk(c, raw)
|
||||
if err := writeOpenaiImageStreamChunk(c, raw); err != nil {
|
||||
sr.Stop(err)
|
||||
}
|
||||
})
|
||||
|
||||
// StreamScannerHandler consumes the upstream [DONE]; re-emit it so the
|
||||
// client still receives a terminal data: [DONE].
|
||||
if info != nil && info.StreamStatus != nil && info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone {
|
||||
if info.StreamStatus != nil && info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone {
|
||||
helper.Done(c)
|
||||
}
|
||||
|
||||
applyUsagePostProcessing(info, usage, lastStreamData)
|
||||
// Only trust completedImages when upstream finished the stream (done/eof).
|
||||
// On client-side aborts (client_gone, or handler_stop from a failed client
|
||||
// write) the counter undercounts what upstream actually generated and
|
||||
// charged, so keep the requested n — otherwise a client could pay for one
|
||||
// image by disconnecting right after the first completed event. The abort
|
||||
// guard only blocks lowering the charge: if completed events already
|
||||
// exceed the recorded n, bill the higher actual count regardless.
|
||||
if info.StreamStatus != nil {
|
||||
upstreamFinished := info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone ||
|
||||
info.StreamStatus.EndReason == relaycommon.StreamEndReasonEOF
|
||||
requestedN := 1.0
|
||||
if n, ok := info.PriceData.OtherRatios()["n"]; ok {
|
||||
requestedN = n
|
||||
}
|
||||
if upstreamFinished || float64(completedImages) > requestedN {
|
||||
updateOpenAIImageCount(info, completedImages)
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// writeOpenaiImageStreamChunk rebuilds the SSE frame for an image stream chunk:
|
||||
// it emits an "event:" line derived from the JSON "type" field (when present)
|
||||
// followed by the verbatim "data:" payload, mirroring helper.ResponseChunkData.
|
||||
func writeOpenaiImageStreamChunk(c *gin.Context, data []byte) {
|
||||
func writeOpenaiImageStreamChunk(c *gin.Context, data []byte) error {
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
_ = common.Unmarshal(data, &payload)
|
||||
if eventName := strings.TrimSpace(payload.Type); eventName != "" {
|
||||
_ = helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: eventName}, string(data))
|
||||
return
|
||||
return helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: eventName}, string(data))
|
||||
}
|
||||
_ = helper.StringData(c, string(data))
|
||||
return helper.StringData(c, string(data))
|
||||
}
|
||||
|
||||
// isOpenAIImageStreamErrorEvent detects upstream error chunks by JSON content
|
||||
@@ -192,7 +230,7 @@ func extractOpenAIImageStreamErrorMessage(data []byte) string {
|
||||
return "upstream image stream returned error event"
|
||||
}
|
||||
|
||||
func OpenaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
func openaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
@@ -200,49 +238,75 @@ func OpenaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo,
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var imageResp dto.ImageResponse
|
||||
if err := common.Unmarshal(responseBody, &imageResp); err != nil {
|
||||
// Only decode usage/error. Do not Unmarshal data[] into dto.ImageResponse —
|
||||
// b64_json values are large and would be copied into Go strings then
|
||||
// re-marshaled for each SSE event.
|
||||
var usageResp dto.SimpleResponse
|
||||
if err := common.Unmarshal(responseBody, &usageResp); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var usageResp dto.SimpleResponse
|
||||
_ = common.Unmarshal(responseBody, &usageResp)
|
||||
if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
normalizeOpenAIUsage(&usageResp.Usage)
|
||||
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
|
||||
|
||||
imageCount := gjson.GetBytes(responseBody, "data.#").Int()
|
||||
updateOpenAIImageCount(info, imageCount)
|
||||
|
||||
helper.SetEventStreamHeaders(c)
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
created := imageResp.Created
|
||||
created := gjson.GetBytes(responseBody, "created").Int()
|
||||
if created == 0 {
|
||||
created = time.Now().Unix()
|
||||
}
|
||||
if info != nil {
|
||||
info.SetFirstResponseTime()
|
||||
}
|
||||
for _, image := range imageResp.Data {
|
||||
payload := map[string]any{
|
||||
"type": "image_generation.completed",
|
||||
"created_at": created,
|
||||
|
||||
validUsage := service.ValidUsage(&usageResp.Usage)
|
||||
var usageJSON []byte
|
||||
if validUsage {
|
||||
usageJSON, err = common.Marshal(usageResp.Usage)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if image.Url != "" {
|
||||
payload["url"] = image.Url
|
||||
}
|
||||
|
||||
for i := int64(0); i < imageCount; i++ {
|
||||
image := gjson.GetBytes(responseBody, "data."+strconv.FormatInt(i, 10))
|
||||
payload := []byte(`{"type":"image_generation.completed"}`)
|
||||
payload, err = sjson.SetBytes(payload, "created_at", created)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if image.B64Json != "" {
|
||||
payload["b64_json"] = image.B64Json
|
||||
if validUsage {
|
||||
payload, err = sjson.SetRawBytes(payload, "usage", usageJSON)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
if image.RevisedPrompt != "" {
|
||||
payload["revised_prompt"] = image.RevisedPrompt
|
||||
// b64_json goes last: every sjson.Set* reallocates the whole payload,
|
||||
// so inserting the large blob after all small fields avoids re-copying
|
||||
// multi-MB buffers.
|
||||
for _, field := range []string{"url", "revised_prompt", "b64_json"} {
|
||||
value := image.Get(field)
|
||||
if value.Type != gjson.String || value.Raw == `""` {
|
||||
continue
|
||||
}
|
||||
raw := []byte(value.Raw)
|
||||
if value.Index > 0 {
|
||||
raw = responseBody[value.Index : value.Index+len(value.Raw)]
|
||||
}
|
||||
payload, err = sjson.SetRawBytes(payload, field, raw)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
if service.ValidUsage(&usageResp.Usage) {
|
||||
payload["usage"] = usageResp.Usage
|
||||
}
|
||||
if err := writeOpenaiImageStreamPayload(c, "image_generation.completed", payload); err != nil {
|
||||
if writeErr := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: "image_generation.completed"}, string(payload)); writeErr != nil {
|
||||
if info != nil && info.StreamStatus != nil {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, writeErr)
|
||||
}
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
@@ -254,7 +318,7 @@ func OpenaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo,
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
if info != nil {
|
||||
info.ReceivedResponseCount += len(imageResp.Data)
|
||||
info.ReceivedResponseCount += int(imageCount)
|
||||
if info.StreamStatus == nil {
|
||||
info.StreamStatus = relaycommon.NewStreamStatus()
|
||||
}
|
||||
@@ -263,17 +327,6 @@ func OpenaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo,
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
|
||||
func writeOpenaiImageStreamPayload(c *gin.Context, eventName string, payload any) error {
|
||||
data, err := common.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if eventName != "" {
|
||||
return helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: eventName}, string(data))
|
||||
}
|
||||
return helper.StringData(c, string(data))
|
||||
}
|
||||
|
||||
func writeOpenaiImageStreamDone(c *gin.Context) error {
|
||||
return helper.StringData(c, "[DONE]")
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+30
-5
@@ -117,12 +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
|
||||
preConsumedQuota = common.QuotaFromFloat(float64(preConsumedTokens) * ratio)
|
||||
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
|
||||
}
|
||||
preConsumedQuota = common.QuotaFromFloat(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
}
|
||||
|
||||
// check if free model pre-consume is disabled
|
||||
@@ -160,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())
|
||||
@@ -199,7 +213,11 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
|
||||
freeModel := false
|
||||
|
||||
if usePrice {
|
||||
quota = common.QuotaFromFloat(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
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 {
|
||||
quota = 0
|
||||
@@ -208,7 +226,11 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
|
||||
}
|
||||
} else {
|
||||
// 按量计费:以模型倍率的一半作为预扣额度
|
||||
quota = common.QuotaFromFloat(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
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 {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
|
||||
@@ -270,7 +292,10 @@ 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 := billingexpr.QuotaRound(quotaBeforeGroup * groupRatioInfo.GroupRatio)
|
||||
preConsumedQuota, err := billingexpr.QuotaRoundStrict(quotaBeforeGroup * groupRatioInfo.GroupRatio)
|
||||
if err != nil {
|
||||
return types.PriceData{}, err
|
||||
}
|
||||
|
||||
freeModel := false
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
|
||||
+135
-1
@@ -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)
|
||||
@@ -138,3 +141,134 @@ func TestModelPriceHelperTieredPreConsumeMaxTokensFallback(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPriceHelperTieredRejectsPreConsumeOverflow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
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": `{"tiered-overflow-model":"tiered_expr"}`,
|
||||
"billing_setting.billing_expr": `{"tiered-overflow-model":"tier(\"overflow\", p * 1000000000000000)"}`,
|
||||
"group_ratio_setting.group_ratio": `{"default":1}`,
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
ctx.Set("group", "default")
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "tiered-overflow-model",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
BillingRequestInput: &billingexpr.RequestInput{
|
||||
Body: []byte(`{}`),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
@@ -17,6 +18,22 @@ const (
|
||||
// PreConsumeBilling 根据用户计费偏好创建 BillingSession 并执行预扣费。
|
||||
// 会话存储在 relayInfo.Billing 上,供后续 Settle / Refund 使用。
|
||||
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||
if relayInfo != nil && relayInfo.QuotaClamp != nil {
|
||||
return types.NewErrorWithStatusCode(
|
||||
relayInfo.QuotaClamp,
|
||||
types.ErrorCodeModelPriceError,
|
||||
http.StatusBadRequest,
|
||||
types.ErrOptionWithSkipRetry(),
|
||||
)
|
||||
}
|
||||
if preConsumedQuota < 0 {
|
||||
return types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("pre-consume quota cannot be negative: %d", preConsumedQuota),
|
||||
types.ErrorCodeModelPriceError,
|
||||
http.StatusBadRequest,
|
||||
types.ErrOptionWithSkipRetry(),
|
||||
)
|
||||
}
|
||||
session, apiErr := NewBillingSession(c, relayInfo, preConsumedQuota)
|
||||
if apiErr != nil {
|
||||
return apiErr
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -72,3 +74,40 @@ func TestAttachQuotaSaturationNoClampNoMarker(t *testing.T) {
|
||||
_, hasAdmin := other["admin_info"]
|
||||
require.False(t, hasAdmin, "no admin_info should be added when there is no clamp")
|
||||
}
|
||||
|
||||
func TestPreConsumeBillingRejectsSaturatedQuotaBeforeDeduction(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
info := &relaycommon.RelayInfo{
|
||||
QuotaClamp: &common.QuotaClamp{
|
||||
Op: "QuotaFromFloat",
|
||||
Kind: common.QuotaClampOverflow,
|
||||
Original: 1e30,
|
||||
Clamped: common.MaxQuota,
|
||||
},
|
||||
}
|
||||
|
||||
apiErr := PreConsumeBilling(c, common.MaxQuota, info)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func TestPreConsumeBillingRejectsNegativeQuotaBeforeDeduction(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
info := &relaycommon.RelayInfo{}
|
||||
|
||||
apiErr := PreConsumeBilling(c, -1, info)
|
||||
|
||||
require.NotNil(t, apiErr)
|
||||
require.Equal(t, types.ErrorCodeModelPriceError, apiErr.GetErrorCode())
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
|
||||
require.Nil(t, info.Billing)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user