mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +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>
133 lines
5.3 KiB
Go
133 lines
5.3 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/tidwall/gjson"
|
||
)
|
||
|
||
// inflightReservationEstimator 估算单请求在途预留金额(USD);false 表示无法定价。
|
||
type inflightReservationEstimator interface {
|
||
EstimateInflightReservation(ctx context.Context, apiKey *service.APIKey, req service.InflightEstimateRequest) (float64, bool)
|
||
}
|
||
|
||
// requestMaxOutputTokens 从请求体中提取输出 token 上限(兼容 Anthropic / OpenAI Chat / Responses / Gemini)。
|
||
func requestMaxOutputTokens(body []byte) int {
|
||
for _, path := range []string{"max_tokens", "max_completion_tokens", "max_output_tokens", "generationConfig.maxOutputTokens", "generation_config.max_output_tokens"} {
|
||
if v := gjson.GetBytes(body, path); v.Exists() && v.Type == gjson.Number && v.Int() > 0 {
|
||
return int(v.Int())
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// tokenInflightEstimate 文本类请求的估算输入。
|
||
func tokenInflightEstimate(model string, body []byte) service.InflightEstimateRequest {
|
||
return service.InflightEstimateRequest{
|
||
Model: model,
|
||
BodyBytes: len(body),
|
||
MaxTokens: requestMaxOutputTokens(body),
|
||
Kind: service.InflightEstimateToken,
|
||
}
|
||
}
|
||
|
||
func inflightNoop() {}
|
||
|
||
// reserveInflightBalance 在 CheckBillingEligibility 之后为余额模式请求登记在途预留。
|
||
//
|
||
// 成功时把预留句柄挂到 c.Request 的 context 上:之后通过 submit*UsageRecordTask 提交的
|
||
// 计费任务会接管一个引用,直到余额缓存实际扣减后才释放。返回的 done 必须 defer 调用
|
||
// (停止续期并归还 handler 引用;没有提交计费任务时即刻释放)。
|
||
// 开关关闭、订阅模式、Redis 故障时返回 no-op(fail-open);无法定价时默认 fail-open,
|
||
// 配置 fail_closed_on_unpriced=true 时返回 ErrInsufficientBalance。
|
||
func reserveInflightBalance(
|
||
c *gin.Context,
|
||
billing *service.BillingCacheService,
|
||
estimator inflightReservationEstimator,
|
||
apiKey *service.APIKey,
|
||
subscription *service.UserSubscription,
|
||
req service.InflightEstimateRequest,
|
||
) (func(), error) {
|
||
if c == nil || c.Request == nil {
|
||
return inflightNoop, nil
|
||
}
|
||
ctx, done, err := reserveInflightBalanceCtx(c.Request.Context(), billing, estimator, apiKey, subscription, req)
|
||
if err != nil {
|
||
return inflightNoop, err
|
||
}
|
||
c.Request = c.Request.WithContext(ctx)
|
||
return done, nil
|
||
}
|
||
|
||
// reserveInflightBalanceCtx 同 reserveInflightBalance,但返回携带预留句柄的新 context
|
||
// (供 WebSocket 等自管 context 的路径)。
|
||
func reserveInflightBalanceCtx(
|
||
ctx context.Context,
|
||
billing *service.BillingCacheService,
|
||
estimator inflightReservationEstimator,
|
||
apiKey *service.APIKey,
|
||
subscription *service.UserSubscription,
|
||
req service.InflightEstimateRequest,
|
||
) (context.Context, func(), error) {
|
||
if billing == nil || estimator == nil || apiKey == nil || apiKey.User == nil || !billing.InflightReservationEnabled() {
|
||
return ctx, inflightNoop, nil
|
||
}
|
||
if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() && subscription != nil {
|
||
return ctx, inflightNoop, nil
|
||
}
|
||
estimate, priced := estimator.EstimateInflightReservation(ctx, apiKey, req)
|
||
if !priced && billing.InflightReservationFailClosedOnUnpriced() {
|
||
return ctx, inflightNoop, service.ErrInsufficientBalance
|
||
}
|
||
if estimate <= 0 {
|
||
return ctx, inflightNoop, nil
|
||
}
|
||
res, err := billing.ReserveInflight(ctx, apiKey.User, apiKey.Group, subscription, estimate)
|
||
if err != nil {
|
||
return ctx, inflightNoop, err
|
||
}
|
||
if res == nil {
|
||
return ctx, inflightNoop, nil
|
||
}
|
||
return service.WithInflightReservation(ctx, res), res.HandlerDone, nil
|
||
}
|
||
|
||
// grokMediaInflightEstimate 媒体生成请求的估算输入;状态/内容查询返回空模型(不预留:
|
||
// 查询会为已生成的媒体计费,不能因余额预留而拦截用户取回已付费结果)。
|
||
func grokMediaInflightEstimate(endpoint service.GrokMediaEndpoint, model string, info service.GrokMediaRequestInfo, body []byte) service.InflightEstimateRequest {
|
||
if !endpoint.IsGenerationRequest() {
|
||
return service.InflightEstimateRequest{}
|
||
}
|
||
switch endpoint {
|
||
case service.GrokMediaEndpointImagesGenerations, service.GrokMediaEndpointImagesEdits:
|
||
return service.InflightEstimateRequest{Model: model, BodyBytes: len(body), Kind: service.InflightEstimateImage, Units: info.N}
|
||
default:
|
||
return service.InflightEstimateRequest{
|
||
Model: model,
|
||
BodyBytes: len(body),
|
||
Kind: service.InflightEstimateVideo,
|
||
Units: 1,
|
||
VideoResolution: info.Resolution,
|
||
VideoDurationSeconds: info.DurationSeconds,
|
||
}
|
||
}
|
||
}
|
||
|
||
// grokVoiceSTTBytesPerSecond STT 时长粗估(~128kbps 压缩音频)。
|
||
const grokVoiceSTTBytesPerSecond = 16000
|
||
|
||
// grokVoiceInflightEstimate 语音 HTTP 接口估算:TTS 按输入字符数(百万字符),STT 按音频字节粗估时长(小时)。
|
||
// 其他接口(custom-voices)无音频计量,返回的估算为 0(不预留)。
|
||
func grokVoiceInflightEstimate(endpoint string, body []byte) service.InflightEstimateRequest {
|
||
req := service.InflightEstimateRequest{Model: endpoint, Kind: service.InflightEstimateAudio, AudioMode: endpoint}
|
||
switch endpoint {
|
||
case "tts":
|
||
req.AudioUnits = float64(len([]rune(extractGrokTTSInputText(body)))) / 1e6
|
||
case "stt":
|
||
req.AudioUnits = float64(len(body)) / grokVoiceSTTBytesPerSecond / 3600
|
||
}
|
||
return req
|
||
}
|