mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Address gaps found in adversarial verification of the in-flight balance reservation: - Early release: the reservation is now a ref-counted handle carried on the request context. Billing tasks submitted via submit*UsageRecordTask take a ref and return it only after RecordUsage has run; with reservation enabled the balance cache is deducted synchronously inside the billing task, so the reservation is released only after admission can see the deduction. Dropped tasks return their ref immediately. The reservation is renewed every ttl/3 while the handler runs; after the handler returns it is no longer renewed, so the post-handler hold is bounded by one TTL. - Estimate: use the same billing model (channel mapping + BillingModelSource, max over requested/mapped when ambiguous) and ModelPricingResolver (group ModelPricing incl. wildcards, channel per_request/image/video/token overrides), default/group/user rate + peak multiplier, plus image, video, audio and search surcharge kinds. Unpriced requests fail open with a throttled log; new billing.inflight_reservation.fail_closed_on_unpriced (default false) rejects them instead. - Wire images, embeddings, web/x search, alpha search, grok media generation, grok realtime/voice, OpenAI-platform /v1/messages and ResponsesWebSocket (session-level reservation with renewal). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
205 lines
7.6 KiB
Go
205 lines
7.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestRequestMaxOutputTokens(t *testing.T) {
|
|
require.Equal(t, 1024, requestMaxOutputTokens([]byte(`{"max_tokens":1024}`)))
|
|
require.Equal(t, 2048, requestMaxOutputTokens([]byte(`{"max_completion_tokens":2048}`)))
|
|
require.Equal(t, 4096, requestMaxOutputTokens([]byte(`{"max_output_tokens":4096}`)))
|
|
require.Equal(t, 512, requestMaxOutputTokens([]byte(`{"generationConfig":{"maxOutputTokens":512}}`)))
|
|
require.Equal(t, 0, requestMaxOutputTokens([]byte(`{"max_tokens":"x"}`)))
|
|
require.Equal(t, 0, requestMaxOutputTokens([]byte(`{}`)))
|
|
}
|
|
|
|
type countingEstimator struct {
|
|
calls int
|
|
cost float64
|
|
priced bool
|
|
}
|
|
|
|
func (e *countingEstimator) EstimateInflightReservation(context.Context, *service.APIKey, service.InflightEstimateRequest) (float64, bool) {
|
|
e.calls++
|
|
return e.cost, e.priced
|
|
}
|
|
|
|
func newInflightTestGinContext() *gin.Context {
|
|
gin.SetMode(gin.TestMode)
|
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
|
return c
|
|
}
|
|
|
|
func TestReserveInflightBalance_SkipsWhenDisabledOrSubscription(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
billing := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
|
t.Cleanup(billing.Stop)
|
|
est := &countingEstimator{cost: 1, priced: true}
|
|
apiKey := &service.APIKey{User: &service.User{ID: 1}}
|
|
|
|
done, err := reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, nil, tokenInflightEstimate("m", []byte(`{}`)))
|
|
require.NoError(t, err)
|
|
done()
|
|
require.Equal(t, 0, est.calls, "disabled switch must not even estimate")
|
|
|
|
cfg.Billing.InflightReservation.Enabled = true
|
|
apiKey.Group = &service.Group{SubscriptionType: service.SubscriptionTypeSubscription}
|
|
done, err = reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, &service.UserSubscription{}, tokenInflightEstimate("m", []byte(`{}`)))
|
|
require.NoError(t, err)
|
|
done()
|
|
require.Equal(t, 0, est.calls, "subscription mode must be unaffected")
|
|
}
|
|
|
|
func TestReserveInflightBalance_UnpricedFailOpenByDefaultFailClosedOptIn(t *testing.T) {
|
|
cache := newHandlerInflightCache(10)
|
|
cfg := &config.Config{}
|
|
cfg.Billing.InflightReservation = config.InflightReservationConfig{Enabled: true, TTLSeconds: 60}
|
|
billing := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
|
|
t.Cleanup(billing.Stop)
|
|
apiKey := &service.APIKey{User: &service.User{ID: 1}}
|
|
est := &countingEstimator{priced: false}
|
|
|
|
done, err := reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, nil, tokenInflightEstimate("unknown", nil))
|
|
require.NoError(t, err)
|
|
done()
|
|
|
|
cfg.Billing.InflightReservation.FailClosedOnUnpriced = true
|
|
_, err = reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, nil, tokenInflightEstimate("unknown", nil))
|
|
require.ErrorIs(t, err, service.ErrInsufficientBalance)
|
|
}
|
|
|
|
// handlerInflightCache 内存版余额缓存 + 在途预留(语义同 Redis Lua)。
|
|
type handlerInflightCache struct {
|
|
service.BillingCache
|
|
mu sync.Mutex
|
|
balance float64
|
|
res map[string]float64
|
|
}
|
|
|
|
func newHandlerInflightCache(balance float64) *handlerInflightCache {
|
|
return &handlerInflightCache{balance: balance, res: map[string]float64{}}
|
|
}
|
|
|
|
func (m *handlerInflightCache) GetUserBalance(context.Context, int64) (float64, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.balance, nil
|
|
}
|
|
|
|
func (m *handlerInflightCache) GetUserPlatformQuotaCache(context.Context, int64, string) (*service.UserPlatformQuotaCacheEntry, bool, error) {
|
|
return nil, false, nil
|
|
}
|
|
|
|
func (m *handlerInflightCache) ReserveInflightBalance(_ context.Context, _ int64, id string, amount, balance float64, _ time.Duration) (bool, float64, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
sum := 0.0
|
|
for _, v := range m.res {
|
|
sum += v
|
|
}
|
|
if len(m.res) > 0 && balance-sum < amount {
|
|
return false, sum, nil
|
|
}
|
|
m.res[id] = amount
|
|
return true, sum, nil
|
|
}
|
|
|
|
func (m *handlerInflightCache) ReleaseInflightBalance(_ context.Context, _ int64, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.res, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *handlerInflightCache) count() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return len(m.res)
|
|
}
|
|
|
|
func TestWrapUsageRecordTaskContext_HandsReservationToBillingTask(t *testing.T) {
|
|
cache := newHandlerInflightCache(1)
|
|
cfg := &config.Config{}
|
|
cfg.Billing.InflightReservation = config.InflightReservationConfig{Enabled: true, TTLSeconds: 60}
|
|
billing := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
|
|
t.Cleanup(billing.Stop)
|
|
apiKey := &service.APIKey{User: &service.User{ID: 5}}
|
|
|
|
c := newInflightTestGinContext()
|
|
done, err := reserveInflightBalance(c, billing, &countingEstimator{cost: 0.9, priced: true}, apiKey, nil, tokenInflightEstimate("m", nil))
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, cache.count())
|
|
|
|
ran := false
|
|
task, abandon := wrapUsageRecordTaskContext(c.Request.Context(), func(context.Context) { ran = true })
|
|
done() // handler returns; billing still pending
|
|
require.Equal(t, 1, cache.count(), "reservation held until the billing task finishes")
|
|
task(context.Background())
|
|
require.True(t, ran)
|
|
require.Equal(t, 0, cache.count())
|
|
abandon() // idempotent with the task's own done
|
|
|
|
// Dropped task: the submitter abandons it and the reservation is released.
|
|
c2 := newInflightTestGinContext()
|
|
done2, err := reserveInflightBalance(c2, billing, &countingEstimator{cost: 0.9, priced: true}, apiKey, nil, tokenInflightEstimate("m", nil))
|
|
require.NoError(t, err)
|
|
_, abandon2 := wrapUsageRecordTaskContext(c2.Request.Context(), func(context.Context) {})
|
|
done2()
|
|
require.Equal(t, 1, cache.count())
|
|
abandon2()
|
|
require.Equal(t, 0, cache.count())
|
|
}
|
|
|
|
// 新接入的端点(独立 web_search):在途预留超过余额时拒绝,且不残留预留。
|
|
func TestWebSearch_RejectsWhenInflightExceedsBalance(t *testing.T) {
|
|
cache := newHandlerInflightCache(1.5)
|
|
cfg := &config.Config{}
|
|
cfg.Billing.InflightReservation = config.InflightReservationConfig{Enabled: true, TTLSeconds: 60}
|
|
billingCache := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
|
|
t.Cleanup(billingCache.Stop)
|
|
billing := service.NewBillingService(cfg, nil)
|
|
gw := service.NewGatewayService(
|
|
nil, nil, nil, nil, nil, nil, nil, nil, cfg, nil, nil, billing, nil, nil,
|
|
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, service.NewModelPricingResolver(nil, billing), nil, nil, nil,
|
|
)
|
|
h := &GatewayHandler{gatewayService: gw, billingCacheService: billingCache}
|
|
|
|
groupID := int64(3)
|
|
perK := 1000.0 // $1 per search
|
|
apiKey := &service.APIKey{
|
|
ID: 9, User: &service.User{ID: 42, Balance: 1.5}, GroupID: &groupID,
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok, RateMultiplier: 1, SearchPricePer1k: &perK},
|
|
}
|
|
|
|
// Another in-flight request of this user already holds $1.
|
|
held, err := billingCache.ReserveInflight(context.Background(), apiKey.User, apiKey.Group, nil, 1.0)
|
|
require.NoError(t, err)
|
|
defer held.HandlerDone()
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/web_search", bytes.NewBufferString(`{"query":"sub2api"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
c.Set(string(middleware2.ContextKeyAPIKey), apiKey)
|
|
|
|
h.WebSearch(c)
|
|
|
|
require.NotEqual(t, http.StatusOK, w.Code)
|
|
require.Contains(t, w.Body.String(), "balance")
|
|
require.Equal(t, 1, cache.count(), "rejected request must not leave a reservation behind")
|
|
}
|