初始化导入 new-api 源码
This commit is contained in:
48
service/audio.go
Normal file
48
service/audio.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseAudio(audioBase64 string, format string) (duration float64, err error) {
|
||||
audioData, err := base64.StdEncoding.DecodeString(audioBase64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("base64 decode error: %v", err)
|
||||
}
|
||||
|
||||
var samplesCount int
|
||||
var sampleRate int
|
||||
|
||||
switch format {
|
||||
case "pcm16":
|
||||
samplesCount = len(audioData) / 2 // 16位 = 2字节每样本
|
||||
sampleRate = 24000 // 24kHz
|
||||
case "g711_ulaw", "g711_alaw":
|
||||
samplesCount = len(audioData) // 8位 = 1字节每样本
|
||||
sampleRate = 8000 // 8kHz
|
||||
default:
|
||||
samplesCount = len(audioData) // 8位 = 1字节每样本
|
||||
sampleRate = 8000 // 8kHz
|
||||
}
|
||||
|
||||
duration = float64(samplesCount) / float64(sampleRate)
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func DecodeBase64AudioData(audioBase64 string) (string, error) {
|
||||
// 检查并移除 data:audio/xxx;base64, 前缀
|
||||
idx := strings.Index(audioBase64, ",")
|
||||
if idx != -1 {
|
||||
audioBase64 = audioBase64[idx+1:]
|
||||
}
|
||||
|
||||
// 解码 Base64 数据
|
||||
_, err := base64.StdEncoding.DecodeString(audioBase64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("base64 decode error: %v", err)
|
||||
}
|
||||
|
||||
return audioBase64, nil
|
||||
}
|
||||
78
service/billing.go
Normal file
78
service/billing.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
BillingSourceWallet = "wallet"
|
||||
BillingSourceSubscription = "subscription"
|
||||
)
|
||||
|
||||
// PreConsumeBilling 根据用户计费偏好创建 BillingSession 并执行预扣费。
|
||||
// 会话存储在 relayInfo.Billing 上,供后续 Settle / Refund 使用。
|
||||
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||
session, apiErr := NewBillingSession(c, relayInfo, preConsumedQuota)
|
||||
if apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
relayInfo.Billing = session
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SettleBilling — 后结算辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SettleBilling 执行计费结算。如果 RelayInfo 上有 BillingSession 则通过 session 结算,
|
||||
// 否则回退到旧的 PostConsumeQuota 路径(兼容按次计费等场景)。
|
||||
func SettleBilling(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, actualQuota int) error {
|
||||
if relayInfo.Billing != nil {
|
||||
preConsumed := relayInfo.Billing.GetPreConsumedQuota()
|
||||
delta := actualQuota - preConsumed
|
||||
|
||||
if delta > 0 {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)",
|
||||
logger.FormatQuota(delta),
|
||||
logger.FormatQuota(actualQuota),
|
||||
logger.FormatQuota(preConsumed),
|
||||
))
|
||||
} else if delta < 0 {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)",
|
||||
logger.FormatQuota(-delta),
|
||||
logger.FormatQuota(actualQuota),
|
||||
logger.FormatQuota(preConsumed),
|
||||
))
|
||||
} else {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("预扣费与实际消耗一致,无需调整:%s(按次计费)",
|
||||
logger.FormatQuota(actualQuota),
|
||||
))
|
||||
}
|
||||
|
||||
if err := relayInfo.Billing.Settle(actualQuota); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发送额度通知(订阅计费使用订阅剩余额度)
|
||||
if actualQuota != 0 {
|
||||
if relayInfo.BillingSource == BillingSourceSubscription {
|
||||
checkAndSendSubscriptionQuotaNotify(relayInfo)
|
||||
} else {
|
||||
checkAndSendQuotaNotify(relayInfo, actualQuota-preConsumed, preConsumed)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 回退:无 BillingSession 时使用旧路径
|
||||
quotaDelta := actualQuota - relayInfo.FinalPreConsumedQuota
|
||||
if quotaDelta != 0 {
|
||||
return PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
314
service/billing_session.go
Normal file
314
service/billing_session.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BillingSession — 统一计费会话
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BillingSession 封装单次请求的预扣费/结算/退款生命周期。
|
||||
// 实现 relaycommon.BillingSettler 接口。
|
||||
type BillingSession struct {
|
||||
relayInfo *relaycommon.RelayInfo
|
||||
funding FundingSource
|
||||
preConsumedQuota int // 实际预扣额度(信任用户可能为 0)
|
||||
tokenConsumed int // 令牌额度实际扣减量
|
||||
fundingSettled bool // funding.Settle 已成功,资金来源已提交
|
||||
settled bool // Settle 全部完成(资金 + 令牌)
|
||||
refunded bool // Refund 已调用
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Settle 根据实际消耗额度进行结算。
|
||||
// 资金来源和令牌额度分两步提交:若资金来源已提交但令牌调整失败,
|
||||
// 会标记 fundingSettled 防止 Refund 对已提交的资金来源执行退款。
|
||||
func (s *BillingSession) Settle(actualQuota int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.settled {
|
||||
return nil
|
||||
}
|
||||
delta := actualQuota - s.preConsumedQuota
|
||||
if delta == 0 {
|
||||
s.settled = true
|
||||
return nil
|
||||
}
|
||||
// 1) 调整资金来源(仅在尚未提交时执行,防止重复调用)
|
||||
if !s.fundingSettled {
|
||||
if err := s.funding.Settle(delta); err != nil {
|
||||
return err
|
||||
}
|
||||
s.fundingSettled = true
|
||||
}
|
||||
// 2) 调整令牌额度
|
||||
var tokenErr error
|
||||
if !s.relayInfo.IsPlayground {
|
||||
if delta > 0 {
|
||||
tokenErr = model.DecreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, delta)
|
||||
} else {
|
||||
tokenErr = model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, -delta)
|
||||
}
|
||||
if tokenErr != nil {
|
||||
common.SysLog(fmt.Sprintf("error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s",
|
||||
s.relayInfo.UserId, s.relayInfo.TokenId, delta, tokenErr.Error()))
|
||||
}
|
||||
}
|
||||
if s.funding.Source() == BillingSourceSubscription {
|
||||
s.relayInfo.SubscriptionPostDelta += int64(delta)
|
||||
}
|
||||
s.settled = true
|
||||
return tokenErr
|
||||
}
|
||||
|
||||
// Refund 退还所有预扣费,幂等安全,异步执行。
|
||||
func (s *BillingSession) Refund(c *gin.Context) {
|
||||
s.mu.Lock()
|
||||
if s.settled || s.refunded || !s.needsRefundLocked() {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.refunded = true
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费(token_quota=%s, funding=%s)",
|
||||
s.relayInfo.UserId,
|
||||
logger.FormatQuota(s.tokenConsumed),
|
||||
s.funding.Source(),
|
||||
))
|
||||
|
||||
tokenId := s.relayInfo.TokenId
|
||||
tokenKey := s.relayInfo.TokenKey
|
||||
isPlayground := s.relayInfo.IsPlayground
|
||||
tokenConsumed := s.tokenConsumed
|
||||
funding := s.funding
|
||||
|
||||
gopool.Go(func() {
|
||||
if err := funding.Refund(); err != nil {
|
||||
common.SysLog("error refunding billing source: " + err.Error())
|
||||
}
|
||||
if tokenConsumed > 0 && !isPlayground {
|
||||
if err := model.IncreaseTokenQuota(tokenId, tokenKey, tokenConsumed); err != nil {
|
||||
common.SysLog("error refunding token quota: " + err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BillingSession) NeedsRefund() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.needsRefundLocked()
|
||||
}
|
||||
|
||||
func (s *BillingSession) needsRefundLocked() bool {
|
||||
if s.settled || s.refunded || s.fundingSettled {
|
||||
return false
|
||||
}
|
||||
if s.tokenConsumed > 0 {
|
||||
return true
|
||||
}
|
||||
if sub, ok := s.funding.(*SubscriptionFunding); ok && sub.preConsumed > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *BillingSession) GetPreConsumedQuota() int {
|
||||
return s.preConsumedQuota
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PreConsume — 统一预扣费入口(含信任额度旁路)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIError {
|
||||
effectiveQuota := quota
|
||||
|
||||
if s.shouldTrust(c) {
|
||||
effectiveQuota = 0
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足, 信任且不需要预扣费 (funding=%s)", s.relayInfo.UserId, s.funding.Source()))
|
||||
} else if effectiveQuota > 0 {
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 需要预扣费 %s (funding=%s)", s.relayInfo.UserId, logger.FormatQuota(effectiveQuota), s.funding.Source()))
|
||||
}
|
||||
|
||||
if effectiveQuota > 0 {
|
||||
if err := PreConsumeTokenQuota(s.relayInfo, effectiveQuota); err != nil {
|
||||
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
s.tokenConsumed = effectiveQuota
|
||||
}
|
||||
|
||||
if err := s.funding.PreConsume(effectiveQuota); err != nil {
|
||||
if s.tokenConsumed > 0 && !s.relayInfo.IsPlayground {
|
||||
if rollbackErr := model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, s.tokenConsumed); rollbackErr != nil {
|
||||
common.SysLog(fmt.Sprintf("error rolling back token quota (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s",
|
||||
s.relayInfo.UserId, s.relayInfo.TokenId, s.tokenConsumed, err.Error(), rollbackErr.Error()))
|
||||
}
|
||||
s.tokenConsumed = 0
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "no active subscription") || strings.Contains(errMsg, "subscription quota insufficient") {
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", errMsg), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
s.preConsumedQuota = effectiveQuota
|
||||
s.syncRelayInfo()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BillingSession) shouldTrust(c *gin.Context) bool {
|
||||
if s.relayInfo.ForcePreConsume {
|
||||
return false
|
||||
}
|
||||
|
||||
trustQuota := common.GetTrustQuota()
|
||||
if trustQuota <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
tokenTrusted := s.relayInfo.TokenUnlimited
|
||||
if !tokenTrusted {
|
||||
tokenQuota := c.GetInt("token_quota")
|
||||
tokenTrusted = tokenQuota > trustQuota
|
||||
}
|
||||
if !tokenTrusted {
|
||||
return false
|
||||
}
|
||||
|
||||
switch s.funding.Source() {
|
||||
case BillingSourceWallet:
|
||||
return s.relayInfo.UserQuota > trustQuota
|
||||
case BillingSourceSubscription:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BillingSession) syncRelayInfo() {
|
||||
info := s.relayInfo
|
||||
info.FinalPreConsumedQuota = s.preConsumedQuota
|
||||
info.BillingSource = s.funding.Source()
|
||||
|
||||
if sub, ok := s.funding.(*SubscriptionFunding); ok {
|
||||
info.SubscriptionId = sub.subscriptionId
|
||||
info.SubscriptionPreConsumed = sub.preConsumed
|
||||
info.SubscriptionPostDelta = 0
|
||||
info.SubscriptionAmountTotal = sub.AmountTotal
|
||||
info.SubscriptionAmountUsedAfterPreConsume = sub.AmountUsedAfter
|
||||
info.SubscriptionPlanId = sub.PlanId
|
||||
info.SubscriptionPlanTitle = sub.PlanTitle
|
||||
} else {
|
||||
info.SubscriptionId = 0
|
||||
info.SubscriptionPreConsumed = 0
|
||||
}
|
||||
}
|
||||
|
||||
func NewBillingSession(c *gin.Context, relayInfo *relaycommon.RelayInfo, preConsumedQuota int) (*BillingSession, *types.NewAPIError) {
|
||||
if relayInfo == nil {
|
||||
return nil, types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
pref := common.NormalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
|
||||
if relayInfo.TokenKey != "" {
|
||||
if token, tokenErr := model.GetTokenByKey(relayInfo.TokenKey, false); tokenErr == nil && token != nil {
|
||||
tokenConsumePref := model.NormalizeTokenConsumeGroup(token.ConsumeGroup)
|
||||
if tokenConsumePref != "" {
|
||||
pref = tokenConsumePref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isSubscriptionGroup, err := model.IsSubscriptionDedicatedGroup(relayInfo.UsingGroup)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
tryWallet := func() (*BillingSession, *types.NewAPIError) {
|
||||
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
if userQuota <= 0 {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)),
|
||||
types.ErrorCodeInsufficientUserQuota, http.StatusForbidden,
|
||||
types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
if userQuota-preConsumedQuota < 0 {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)),
|
||||
types.ErrorCodeInsufficientUserQuota, http.StatusForbidden,
|
||||
types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
relayInfo.UserQuota = userQuota
|
||||
session := &BillingSession{relayInfo: relayInfo, funding: &WalletFunding{userId: relayInfo.UserId}}
|
||||
if apiErr := session.preConsume(c, preConsumedQuota); apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
trySubscription := func() (*BillingSession, *types.NewAPIError) {
|
||||
subConsume := int64(preConsumedQuota)
|
||||
if subConsume <= 0 {
|
||||
subConsume = 1
|
||||
}
|
||||
session := &BillingSession{
|
||||
relayInfo: relayInfo,
|
||||
funding: &SubscriptionFunding{
|
||||
requestId: relayInfo.RequestId,
|
||||
userId: relayInfo.UserId,
|
||||
modelName: relayInfo.OriginModelName,
|
||||
amount: subConsume,
|
||||
group: relayInfo.UsingGroup,
|
||||
},
|
||||
}
|
||||
if apiErr := session.preConsume(c, int(subConsume)); apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
if isSubscriptionGroup {
|
||||
switch pref {
|
||||
case model.TokenConsumeGroupWalletOnly:
|
||||
return nil, types.NewErrorWithStatusCode(fmt.Errorf("当前请求分组是订阅专属分组,仅允许订阅扣费"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
default:
|
||||
return trySubscription()
|
||||
}
|
||||
}
|
||||
|
||||
subAllowed, subAllowedErr := model.IsGroupAllowedByAnyActiveSubscription(relayInfo.UserId, relayInfo.UsingGroup)
|
||||
if subAllowedErr != nil {
|
||||
return nil, types.NewError(subAllowedErr, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
switch pref {
|
||||
case model.TokenConsumeGroupSubscriptionOnly:
|
||||
if !subAllowed {
|
||||
return nil, types.NewErrorWithStatusCode(fmt.Errorf("当前请求分组不在订阅可消费分组内,不能使用订阅扣费"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
return trySubscription()
|
||||
case model.TokenConsumeGroupWalletOnly:
|
||||
return tryWallet()
|
||||
default:
|
||||
return tryWallet()
|
||||
}
|
||||
}
|
||||
115
service/channel.go
Normal file
115
service/channel.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
func formatNotifyType(channelId int, status int) string {
|
||||
return fmt.Sprintf("%s_%d_%d", dto.NotifyTypeChannelUpdate, channelId, status)
|
||||
}
|
||||
|
||||
// disable & notify
|
||||
func DisableChannel(channelError types.ChannelError, reason string) {
|
||||
common.SysLog(fmt.Sprintf("通道「%s」(#%d)发生错误,准备禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason))
|
||||
|
||||
// 检查是否启用自动禁用功能
|
||||
if !channelError.AutoBan {
|
||||
common.SysLog(fmt.Sprintf("通道「%s」(#%d)未启用自动禁用功能,跳过禁用操作", channelError.ChannelName, channelError.ChannelId))
|
||||
return
|
||||
}
|
||||
|
||||
success := model.UpdateChannelStatus(channelError.ChannelId, channelError.UsingKey, common.ChannelStatusAutoDisabled, reason)
|
||||
if success {
|
||||
subject := fmt.Sprintf("通道「%s」(#%d)已被禁用", channelError.ChannelName, channelError.ChannelId)
|
||||
content := fmt.Sprintf("通道「%s」(#%d)已被禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason)
|
||||
NotifyRootUser(formatNotifyType(channelError.ChannelId, common.ChannelStatusAutoDisabled), subject, content)
|
||||
}
|
||||
}
|
||||
|
||||
func EnableChannel(channelId int, usingKey string, channelName string) {
|
||||
success := model.UpdateChannelStatus(channelId, usingKey, common.ChannelStatusEnabled, "")
|
||||
if success {
|
||||
subject := fmt.Sprintf("通道「%s」(#%d)已被启用", channelName, channelId)
|
||||
content := fmt.Sprintf("通道「%s」(#%d)已被启用", channelName, channelId)
|
||||
NotifyRootUser(formatNotifyType(channelId, common.ChannelStatusEnabled), subject, content)
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
|
||||
if !common.AutomaticDisableChannelEnabled {
|
||||
return false
|
||||
}
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if types.IsChannelError(err) {
|
||||
return true
|
||||
}
|
||||
if types.IsSkipRetryError(err) {
|
||||
return false
|
||||
}
|
||||
if operation_setting.ShouldDisableByStatusCode(err.StatusCode) {
|
||||
return true
|
||||
}
|
||||
//if err.StatusCode == http.StatusUnauthorized {
|
||||
// return true
|
||||
//}
|
||||
if err.StatusCode == http.StatusForbidden {
|
||||
switch channelType {
|
||||
case constant.ChannelTypeGemini:
|
||||
return true
|
||||
}
|
||||
}
|
||||
oaiErr := err.ToOpenAIError()
|
||||
switch oaiErr.Code {
|
||||
case "invalid_api_key":
|
||||
return true
|
||||
case "account_deactivated":
|
||||
return true
|
||||
case "billing_not_active":
|
||||
return true
|
||||
case "pre_consume_token_quota_failed":
|
||||
return true
|
||||
case "Arrearage":
|
||||
return true
|
||||
}
|
||||
switch oaiErr.Type {
|
||||
case "insufficient_quota":
|
||||
return true
|
||||
case "insufficient_user_quota":
|
||||
return true
|
||||
// https://docs.anthropic.com/claude/reference/errors
|
||||
case "authentication_error":
|
||||
return true
|
||||
case "permission_error":
|
||||
return true
|
||||
case "forbidden":
|
||||
return true
|
||||
}
|
||||
|
||||
lowerMessage := strings.ToLower(err.Error())
|
||||
search, _ := AcSearch(lowerMessage, operation_setting.AutomaticDisableKeywords, true)
|
||||
return search
|
||||
}
|
||||
|
||||
func ShouldEnableChannel(newAPIError *types.NewAPIError, status int) bool {
|
||||
if !common.AutomaticEnableChannelEnabled {
|
||||
return false
|
||||
}
|
||||
if newAPIError != nil {
|
||||
return false
|
||||
}
|
||||
if status != common.ChannelStatusAutoDisabled {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
953
service/channel_affinity.go
Normal file
953
service/channel_affinity.go
Normal file
@@ -0,0 +1,953 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/pkg/cachex"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/hot"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
ginKeyChannelAffinityCacheKey = "channel_affinity_cache_key"
|
||||
ginKeyChannelAffinityTTLSeconds = "channel_affinity_ttl_seconds"
|
||||
ginKeyChannelAffinityMeta = "channel_affinity_meta"
|
||||
ginKeyChannelAffinityLogInfo = "channel_affinity_log_info"
|
||||
ginKeyChannelAffinitySkipRetry = "channel_affinity_skip_retry_on_failure"
|
||||
|
||||
channelAffinityCacheNamespace = "new-api:channel_affinity:v1"
|
||||
channelAffinityUsageCacheStatsNamespace = "new-api:channel_affinity_usage_cache_stats:v1"
|
||||
)
|
||||
|
||||
var (
|
||||
channelAffinityCacheOnce sync.Once
|
||||
channelAffinityCache *cachex.HybridCache[int]
|
||||
|
||||
channelAffinityUsageCacheStatsOnce sync.Once
|
||||
channelAffinityUsageCacheStatsCache *cachex.HybridCache[ChannelAffinityUsageCacheCounters]
|
||||
|
||||
channelAffinityRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
)
|
||||
|
||||
type channelAffinityMeta struct {
|
||||
CacheKey string
|
||||
TTLSeconds int
|
||||
RuleName string
|
||||
SkipRetry bool
|
||||
ParamTemplate map[string]interface{}
|
||||
KeySourceType string
|
||||
KeySourceKey string
|
||||
KeySourcePath string
|
||||
KeyHint string
|
||||
KeyFingerprint string
|
||||
UsingGroup string
|
||||
ModelName string
|
||||
RequestPath string
|
||||
}
|
||||
|
||||
type ChannelAffinityStatsContext struct {
|
||||
RuleName string
|
||||
UsingGroup string
|
||||
KeyFingerprint string
|
||||
TTLSeconds int64
|
||||
}
|
||||
|
||||
const (
|
||||
cacheTokenRateModeCachedOverPrompt = "cached_over_prompt"
|
||||
cacheTokenRateModeCachedOverPromptPlusCached = "cached_over_prompt_plus_cached"
|
||||
cacheTokenRateModeMixed = "mixed"
|
||||
)
|
||||
|
||||
type ChannelAffinityCacheStats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Total int `json:"total"`
|
||||
Unknown int `json:"unknown"`
|
||||
ByRuleName map[string]int `json:"by_rule_name"`
|
||||
CacheCapacity int `json:"cache_capacity"`
|
||||
CacheAlgo string `json:"cache_algo"`
|
||||
}
|
||||
|
||||
func getChannelAffinityCache() *cachex.HybridCache[int] {
|
||||
channelAffinityCacheOnce.Do(func() {
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
capacity := setting.MaxEntries
|
||||
if capacity <= 0 {
|
||||
capacity = 100_000
|
||||
}
|
||||
defaultTTLSeconds := setting.DefaultTTLSeconds
|
||||
if defaultTTLSeconds <= 0 {
|
||||
defaultTTLSeconds = 3600
|
||||
}
|
||||
|
||||
channelAffinityCache = cachex.NewHybridCache[int](cachex.HybridCacheConfig[int]{
|
||||
Namespace: cachex.Namespace(channelAffinityCacheNamespace),
|
||||
Redis: common.RDB,
|
||||
RedisEnabled: func() bool {
|
||||
return common.RedisEnabled && common.RDB != nil
|
||||
},
|
||||
RedisCodec: cachex.IntCodec{},
|
||||
Memory: func() *hot.HotCache[string, int] {
|
||||
return hot.NewHotCache[string, int](hot.LRU, capacity).
|
||||
WithTTL(time.Duration(defaultTTLSeconds) * time.Second).
|
||||
WithJanitor().
|
||||
Build()
|
||||
},
|
||||
})
|
||||
})
|
||||
return channelAffinityCache
|
||||
}
|
||||
|
||||
func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
if setting == nil {
|
||||
return ChannelAffinityCacheStats{
|
||||
Enabled: false,
|
||||
Total: 0,
|
||||
Unknown: 0,
|
||||
ByRuleName: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
cache := getChannelAffinityCache()
|
||||
mainCap, _ := cache.Capacity()
|
||||
mainAlgo, _ := cache.Algorithm()
|
||||
|
||||
rules := setting.Rules
|
||||
ruleByName := make(map[string]operation_setting.ChannelAffinityRule, len(rules))
|
||||
for _, r := range rules {
|
||||
name := strings.TrimSpace(r.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !r.IncludeRuleName {
|
||||
continue
|
||||
}
|
||||
ruleByName[name] = r
|
||||
}
|
||||
|
||||
byRuleName := make(map[string]int, len(ruleByName))
|
||||
for name := range ruleByName {
|
||||
byRuleName[name] = 0
|
||||
}
|
||||
|
||||
keys, err := cache.Keys()
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("channel affinity cache list keys failed: err=%v", err))
|
||||
keys = nil
|
||||
}
|
||||
total := len(keys)
|
||||
unknown := 0
|
||||
for _, k := range keys {
|
||||
prefix := channelAffinityCacheNamespace + ":"
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimPrefix(k, prefix)
|
||||
parts := strings.Split(rest, ":")
|
||||
if len(parts) < 2 {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
ruleName := parts[0]
|
||||
rule, ok := ruleByName[ruleName]
|
||||
if !ok {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
if rule.IncludeUsingGroup {
|
||||
if len(parts) < 3 {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
}
|
||||
byRuleName[ruleName]++
|
||||
}
|
||||
|
||||
return ChannelAffinityCacheStats{
|
||||
Enabled: setting.Enabled,
|
||||
Total: total,
|
||||
Unknown: unknown,
|
||||
ByRuleName: byRuleName,
|
||||
CacheCapacity: mainCap,
|
||||
CacheAlgo: mainAlgo,
|
||||
}
|
||||
}
|
||||
|
||||
func ClearChannelAffinityCacheAll() int {
|
||||
cache := getChannelAffinityCache()
|
||||
keys, err := cache.Keys()
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("channel affinity cache list keys failed: err=%v", err))
|
||||
keys = nil
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
if _, err := cache.DeleteMany(keys); err != nil {
|
||||
common.SysError(fmt.Sprintf("channel affinity cache delete many failed: err=%v", err))
|
||||
}
|
||||
}
|
||||
return len(keys)
|
||||
}
|
||||
|
||||
func ClearChannelAffinityCacheByRuleName(ruleName string) (int, error) {
|
||||
ruleName = strings.TrimSpace(ruleName)
|
||||
if ruleName == "" {
|
||||
return 0, fmt.Errorf("rule_name 不能为空")
|
||||
}
|
||||
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
if setting == nil {
|
||||
return 0, fmt.Errorf("channel_affinity_setting 未初始化")
|
||||
}
|
||||
|
||||
var matchedRule *operation_setting.ChannelAffinityRule
|
||||
for i := range setting.Rules {
|
||||
r := &setting.Rules[i]
|
||||
if strings.TrimSpace(r.Name) != ruleName {
|
||||
continue
|
||||
}
|
||||
matchedRule = r
|
||||
break
|
||||
}
|
||||
if matchedRule == nil {
|
||||
return 0, fmt.Errorf("未知规则名称")
|
||||
}
|
||||
if !matchedRule.IncludeRuleName {
|
||||
return 0, fmt.Errorf("该规则未启用 include_rule_name,无法按规则清空缓存")
|
||||
}
|
||||
|
||||
cache := getChannelAffinityCache()
|
||||
deleted, err := cache.DeleteByPrefix(ruleName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func matchAnyRegexCached(patterns []string, s string) bool {
|
||||
if len(patterns) == 0 || s == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
re, ok := channelAffinityRegexCache.Load(pattern)
|
||||
if !ok {
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
re = compiled
|
||||
channelAffinityRegexCache.Store(pattern, re)
|
||||
}
|
||||
if re.(*regexp.Regexp).MatchString(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchAnyIncludeFold(patterns []string, s string) bool {
|
||||
if len(patterns) == 0 || s == "" {
|
||||
return false
|
||||
}
|
||||
sLower := strings.ToLower(s)
|
||||
for _, p := range patterns {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(sLower, strings.ToLower(p)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAffinityKeySource) string {
|
||||
switch src.Type {
|
||||
case "context_int":
|
||||
if src.Key == "" {
|
||||
return ""
|
||||
}
|
||||
v := c.GetInt(src.Key)
|
||||
if v <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(v)
|
||||
case "context_string":
|
||||
if src.Key == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.GetString(src.Key))
|
||||
case "gjson":
|
||||
if src.Path == "" {
|
||||
return ""
|
||||
}
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
body, err := storage.Bytes()
|
||||
if err != nil || len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
res := gjson.GetBytes(body, src.Path)
|
||||
if !res.Exists() {
|
||||
return ""
|
||||
}
|
||||
switch res.Type {
|
||||
case gjson.String, gjson.Number, gjson.True, gjson.False:
|
||||
return strings.TrimSpace(res.String())
|
||||
default:
|
||||
return strings.TrimSpace(res.Raw)
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if rule.IncludeRuleName && rule.Name != "" {
|
||||
parts = append(parts, rule.Name)
|
||||
}
|
||||
if rule.IncludeUsingGroup && usingGroup != "" {
|
||||
parts = append(parts, usingGroup)
|
||||
}
|
||||
parts = append(parts, affinityValue)
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func setChannelAffinityContext(c *gin.Context, meta channelAffinityMeta) {
|
||||
c.Set(ginKeyChannelAffinityCacheKey, meta.CacheKey)
|
||||
c.Set(ginKeyChannelAffinityTTLSeconds, meta.TTLSeconds)
|
||||
c.Set(ginKeyChannelAffinityMeta, meta)
|
||||
}
|
||||
|
||||
func getChannelAffinityContext(c *gin.Context) (string, int, bool) {
|
||||
keyAny, ok := c.Get(ginKeyChannelAffinityCacheKey)
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
key, ok := keyAny.(string)
|
||||
if !ok || key == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
ttlAny, ok := c.Get(ginKeyChannelAffinityTTLSeconds)
|
||||
if !ok {
|
||||
return key, 0, true
|
||||
}
|
||||
ttlSeconds, _ := ttlAny.(int)
|
||||
return key, ttlSeconds, true
|
||||
}
|
||||
|
||||
func getChannelAffinityMeta(c *gin.Context) (channelAffinityMeta, bool) {
|
||||
anyMeta, ok := c.Get(ginKeyChannelAffinityMeta)
|
||||
if !ok {
|
||||
return channelAffinityMeta{}, false
|
||||
}
|
||||
meta, ok := anyMeta.(channelAffinityMeta)
|
||||
if !ok {
|
||||
return channelAffinityMeta{}, false
|
||||
}
|
||||
return meta, true
|
||||
}
|
||||
|
||||
func GetChannelAffinityStatsContext(c *gin.Context) (ChannelAffinityStatsContext, bool) {
|
||||
if c == nil {
|
||||
return ChannelAffinityStatsContext{}, false
|
||||
}
|
||||
meta, ok := getChannelAffinityMeta(c)
|
||||
if !ok {
|
||||
return ChannelAffinityStatsContext{}, false
|
||||
}
|
||||
ruleName := strings.TrimSpace(meta.RuleName)
|
||||
keyFp := strings.TrimSpace(meta.KeyFingerprint)
|
||||
usingGroup := strings.TrimSpace(meta.UsingGroup)
|
||||
if ruleName == "" || keyFp == "" {
|
||||
return ChannelAffinityStatsContext{}, false
|
||||
}
|
||||
ttlSeconds := int64(meta.TTLSeconds)
|
||||
if ttlSeconds <= 0 {
|
||||
return ChannelAffinityStatsContext{}, false
|
||||
}
|
||||
return ChannelAffinityStatsContext{
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFp,
|
||||
TTLSeconds: ttlSeconds,
|
||||
}, true
|
||||
}
|
||||
|
||||
func affinityFingerprint(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
hex := common.Sha1([]byte(s))
|
||||
if len(hex) >= 8 {
|
||||
return hex[:8]
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
func buildChannelAffinityKeyHint(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
if len(s) <= 12 {
|
||||
return s
|
||||
}
|
||||
return s[:4] + "..." + s[len(s)-4:]
|
||||
}
|
||||
|
||||
func cloneStringAnyMap(src map[string]interface{}) map[string]interface{} {
|
||||
if len(src) == 0 {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func mergeChannelOverride(base map[string]interface{}, tpl map[string]interface{}) map[string]interface{} {
|
||||
if len(base) == 0 && len(tpl) == 0 {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
if len(tpl) == 0 {
|
||||
return base
|
||||
}
|
||||
out := cloneStringAnyMap(base)
|
||||
for k, v := range tpl {
|
||||
if strings.EqualFold(strings.TrimSpace(k), "operations") {
|
||||
baseOps, hasBaseOps := extractParamOperations(out[k])
|
||||
tplOps, hasTplOps := extractParamOperations(v)
|
||||
if hasTplOps {
|
||||
if hasBaseOps {
|
||||
out[k] = append(tplOps, baseOps...)
|
||||
} else {
|
||||
out[k] = tplOps
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, exists := out[k]; exists {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractParamOperations(value interface{}) ([]interface{}, bool) {
|
||||
switch ops := value.(type) {
|
||||
case []interface{}:
|
||||
if len(ops) == 0 {
|
||||
return []interface{}{}, true
|
||||
}
|
||||
cloned := make([]interface{}, 0, len(ops))
|
||||
cloned = append(cloned, ops...)
|
||||
return cloned, true
|
||||
case []map[string]interface{}:
|
||||
cloned := make([]interface{}, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
cloned = append(cloned, op)
|
||||
}
|
||||
return cloned, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func appendChannelAffinityTemplateAdminInfo(c *gin.Context, meta channelAffinityMeta) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if len(meta.ParamTemplate) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
templateInfo := map[string]interface{}{
|
||||
"applied": true,
|
||||
"rule_name": meta.RuleName,
|
||||
"param_override_keys": len(meta.ParamTemplate),
|
||||
}
|
||||
if anyInfo, ok := c.Get(ginKeyChannelAffinityLogInfo); ok {
|
||||
if info, ok := anyInfo.(map[string]interface{}); ok {
|
||||
info["override_template"] = templateInfo
|
||||
c.Set(ginKeyChannelAffinityLogInfo, info)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Set(ginKeyChannelAffinityLogInfo, map[string]interface{}{
|
||||
"reason": meta.RuleName,
|
||||
"rule_name": meta.RuleName,
|
||||
"using_group": meta.UsingGroup,
|
||||
"model": meta.ModelName,
|
||||
"request_path": meta.RequestPath,
|
||||
"key_source": meta.KeySourceType,
|
||||
"key_key": meta.KeySourceKey,
|
||||
"key_path": meta.KeySourcePath,
|
||||
"key_hint": meta.KeyHint,
|
||||
"key_fp": meta.KeyFingerprint,
|
||||
"override_template": templateInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyChannelAffinityOverrideTemplate merges per-rule channel override templates onto the selected channel override config.
|
||||
func ApplyChannelAffinityOverrideTemplate(c *gin.Context, paramOverride map[string]interface{}) (map[string]interface{}, bool) {
|
||||
if c == nil {
|
||||
return paramOverride, false
|
||||
}
|
||||
meta, ok := getChannelAffinityMeta(c)
|
||||
if !ok {
|
||||
return paramOverride, false
|
||||
}
|
||||
if len(meta.ParamTemplate) == 0 {
|
||||
return paramOverride, false
|
||||
}
|
||||
|
||||
mergedParam := mergeChannelOverride(paramOverride, meta.ParamTemplate)
|
||||
appendChannelAffinityTemplateAdminInfo(c, meta)
|
||||
return mergedParam, true
|
||||
}
|
||||
|
||||
func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) {
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
if setting == nil || !setting.Enabled {
|
||||
return 0, false
|
||||
}
|
||||
path := ""
|
||||
if c != nil && c.Request != nil && c.Request.URL != nil {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
userAgent := ""
|
||||
if c != nil && c.Request != nil {
|
||||
userAgent = c.Request.UserAgent()
|
||||
}
|
||||
|
||||
for _, rule := range setting.Rules {
|
||||
if !matchAnyRegexCached(rule.ModelRegex, modelName) {
|
||||
continue
|
||||
}
|
||||
if len(rule.PathRegex) > 0 && !matchAnyRegexCached(rule.PathRegex, path) {
|
||||
continue
|
||||
}
|
||||
if len(rule.UserAgentInclude) > 0 && !matchAnyIncludeFold(rule.UserAgentInclude, userAgent) {
|
||||
continue
|
||||
}
|
||||
var affinityValue string
|
||||
var usedSource operation_setting.ChannelAffinityKeySource
|
||||
for _, src := range rule.KeySources {
|
||||
affinityValue = extractChannelAffinityValue(c, src)
|
||||
if affinityValue != "" {
|
||||
usedSource = src
|
||||
break
|
||||
}
|
||||
}
|
||||
if affinityValue == "" {
|
||||
continue
|
||||
}
|
||||
if rule.ValueRegex != "" && !matchAnyRegexCached([]string{rule.ValueRegex}, affinityValue) {
|
||||
continue
|
||||
}
|
||||
|
||||
ttlSeconds := rule.TTLSeconds
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = setting.DefaultTTLSeconds
|
||||
}
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
|
||||
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
|
||||
setChannelAffinityContext(c, channelAffinityMeta{
|
||||
CacheKey: cacheKeyFull,
|
||||
TTLSeconds: ttlSeconds,
|
||||
RuleName: rule.Name,
|
||||
SkipRetry: rule.SkipRetryOnFailure,
|
||||
ParamTemplate: cloneStringAnyMap(rule.ParamOverrideTemplate),
|
||||
KeySourceType: strings.TrimSpace(usedSource.Type),
|
||||
KeySourceKey: strings.TrimSpace(usedSource.Key),
|
||||
KeySourcePath: strings.TrimSpace(usedSource.Path),
|
||||
KeyHint: buildChannelAffinityKeyHint(affinityValue),
|
||||
KeyFingerprint: affinityFingerprint(affinityValue),
|
||||
UsingGroup: usingGroup,
|
||||
ModelName: modelName,
|
||||
RequestPath: path,
|
||||
})
|
||||
|
||||
cache := getChannelAffinityCache()
|
||||
channelID, found, err := cache.Get(cacheKeySuffix)
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("channel affinity cache get failed: key=%s, err=%v", cacheKeyFull, err))
|
||||
return 0, false
|
||||
}
|
||||
if found {
|
||||
return channelID, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := c.Get(ginKeyChannelAffinitySkipRetry)
|
||||
if ok {
|
||||
b, ok := v.(bool)
|
||||
if ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
meta, ok := getChannelAffinityMeta(c)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return meta.SkipRetry
|
||||
}
|
||||
|
||||
func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
|
||||
if c == nil || channelID <= 0 {
|
||||
return
|
||||
}
|
||||
meta, ok := getChannelAffinityMeta(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Set(ginKeyChannelAffinitySkipRetry, meta.SkipRetry)
|
||||
info := map[string]interface{}{
|
||||
"reason": meta.RuleName,
|
||||
"rule_name": meta.RuleName,
|
||||
"using_group": meta.UsingGroup,
|
||||
"selected_group": selectedGroup,
|
||||
"model": meta.ModelName,
|
||||
"request_path": meta.RequestPath,
|
||||
"channel_id": channelID,
|
||||
"key_source": meta.KeySourceType,
|
||||
"key_key": meta.KeySourceKey,
|
||||
"key_path": meta.KeySourcePath,
|
||||
"key_hint": meta.KeyHint,
|
||||
"key_fp": meta.KeyFingerprint,
|
||||
}
|
||||
c.Set(ginKeyChannelAffinityLogInfo, info)
|
||||
}
|
||||
|
||||
func AppendChannelAffinityAdminInfo(c *gin.Context, adminInfo map[string]interface{}) {
|
||||
if c == nil || adminInfo == nil {
|
||||
return
|
||||
}
|
||||
anyInfo, ok := c.Get(ginKeyChannelAffinityLogInfo)
|
||||
if !ok || anyInfo == nil {
|
||||
return
|
||||
}
|
||||
adminInfo["channel_affinity"] = anyInfo
|
||||
}
|
||||
|
||||
func RecordChannelAffinity(c *gin.Context, channelID int) {
|
||||
if channelID <= 0 {
|
||||
return
|
||||
}
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
if setting == nil || !setting.Enabled {
|
||||
return
|
||||
}
|
||||
if setting.SwitchOnSuccess && c != nil {
|
||||
if successChannelID := c.GetInt("channel_id"); successChannelID > 0 {
|
||||
channelID = successChannelID
|
||||
}
|
||||
}
|
||||
cacheKey, ttlSeconds, ok := getChannelAffinityContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = setting.DefaultTTLSeconds
|
||||
}
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = 3600
|
||||
}
|
||||
cache := getChannelAffinityCache()
|
||||
if err := cache.SetWithTTL(cacheKey, channelID, time.Duration(ttlSeconds)*time.Second); err != nil {
|
||||
common.SysError(fmt.Sprintf("channel affinity cache set failed: key=%s, err=%v", cacheKey, err))
|
||||
}
|
||||
}
|
||||
|
||||
type ChannelAffinityUsageCacheStats struct {
|
||||
RuleName string `json:"rule_name"`
|
||||
UsingGroup string `json:"using_group"`
|
||||
KeyFingerprint string `json:"key_fp"`
|
||||
CachedTokenRateMode string `json:"cached_token_rate_mode"`
|
||||
|
||||
Hit int64 `json:"hit"`
|
||||
Total int64 `json:"total"`
|
||||
WindowSeconds int64 `json:"window_seconds"`
|
||||
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
PromptCacheHitTokens int64 `json:"prompt_cache_hit_tokens"`
|
||||
LastSeenAt int64 `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type ChannelAffinityUsageCacheCounters struct {
|
||||
CachedTokenRateMode string `json:"cached_token_rate_mode"`
|
||||
|
||||
Hit int64 `json:"hit"`
|
||||
Total int64 `json:"total"`
|
||||
WindowSeconds int64 `json:"window_seconds"`
|
||||
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
PromptCacheHitTokens int64 `json:"prompt_cache_hit_tokens"`
|
||||
LastSeenAt int64 `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
var channelAffinityUsageCacheStatsLocks [64]sync.Mutex
|
||||
|
||||
// ObserveChannelAffinityUsageCacheByRelayFormat records usage cache stats with a stable rate mode derived from relay format.
|
||||
func ObserveChannelAffinityUsageCacheByRelayFormat(c *gin.Context, usage *dto.Usage, relayFormat types.RelayFormat) {
|
||||
ObserveChannelAffinityUsageCacheFromContext(c, usage, cachedTokenRateModeByRelayFormat(relayFormat))
|
||||
}
|
||||
|
||||
func ObserveChannelAffinityUsageCacheFromContext(c *gin.Context, usage *dto.Usage, cachedTokenRateMode string) {
|
||||
statsCtx, ok := GetChannelAffinityStatsContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
observeChannelAffinityUsageCache(statsCtx, usage, cachedTokenRateMode)
|
||||
}
|
||||
|
||||
func GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp string) ChannelAffinityUsageCacheStats {
|
||||
ruleName = strings.TrimSpace(ruleName)
|
||||
usingGroup = strings.TrimSpace(usingGroup)
|
||||
keyFp = strings.TrimSpace(keyFp)
|
||||
|
||||
entryKey := channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFp)
|
||||
if entryKey == "" {
|
||||
return ChannelAffinityUsageCacheStats{
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFp,
|
||||
}
|
||||
}
|
||||
|
||||
cache := getChannelAffinityUsageCacheStatsCache()
|
||||
v, found, err := cache.Get(entryKey)
|
||||
if err != nil || !found {
|
||||
return ChannelAffinityUsageCacheStats{
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFp,
|
||||
}
|
||||
}
|
||||
return ChannelAffinityUsageCacheStats{
|
||||
CachedTokenRateMode: v.CachedTokenRateMode,
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFp,
|
||||
Hit: v.Hit,
|
||||
Total: v.Total,
|
||||
WindowSeconds: v.WindowSeconds,
|
||||
PromptTokens: v.PromptTokens,
|
||||
CompletionTokens: v.CompletionTokens,
|
||||
TotalTokens: v.TotalTokens,
|
||||
CachedTokens: v.CachedTokens,
|
||||
PromptCacheHitTokens: v.PromptCacheHitTokens,
|
||||
LastSeenAt: v.LastSeenAt,
|
||||
}
|
||||
}
|
||||
|
||||
func observeChannelAffinityUsageCache(statsCtx ChannelAffinityStatsContext, usage *dto.Usage, cachedTokenRateMode string) {
|
||||
entryKey := channelAffinityUsageCacheEntryKey(statsCtx.RuleName, statsCtx.UsingGroup, statsCtx.KeyFingerprint)
|
||||
if entryKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
windowSeconds := statsCtx.TTLSeconds
|
||||
if windowSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cache := getChannelAffinityUsageCacheStatsCache()
|
||||
ttl := time.Duration(windowSeconds) * time.Second
|
||||
|
||||
lock := channelAffinityUsageCacheStatsLock(entryKey)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
prev, found, err := cache.Get(entryKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
next := prev
|
||||
if !found {
|
||||
next = ChannelAffinityUsageCacheCounters{}
|
||||
}
|
||||
currentMode := normalizeCachedTokenRateMode(cachedTokenRateMode)
|
||||
if currentMode != "" {
|
||||
if next.CachedTokenRateMode == "" {
|
||||
next.CachedTokenRateMode = currentMode
|
||||
} else if next.CachedTokenRateMode != currentMode && next.CachedTokenRateMode != cacheTokenRateModeMixed {
|
||||
next.CachedTokenRateMode = cacheTokenRateModeMixed
|
||||
}
|
||||
}
|
||||
next.Total++
|
||||
hit, cachedTokens, promptCacheHitTokens := usageCacheSignals(usage)
|
||||
if hit {
|
||||
next.Hit++
|
||||
}
|
||||
next.WindowSeconds = windowSeconds
|
||||
next.LastSeenAt = time.Now().Unix()
|
||||
next.CachedTokens += cachedTokens
|
||||
next.PromptCacheHitTokens += promptCacheHitTokens
|
||||
next.PromptTokens += int64(usagePromptTokens(usage))
|
||||
next.CompletionTokens += int64(usageCompletionTokens(usage))
|
||||
next.TotalTokens += int64(usageTotalTokens(usage))
|
||||
_ = cache.SetWithTTL(entryKey, next, ttl)
|
||||
}
|
||||
|
||||
func normalizeCachedTokenRateMode(mode string) string {
|
||||
switch mode {
|
||||
case cacheTokenRateModeCachedOverPrompt:
|
||||
return cacheTokenRateModeCachedOverPrompt
|
||||
case cacheTokenRateModeCachedOverPromptPlusCached:
|
||||
return cacheTokenRateModeCachedOverPromptPlusCached
|
||||
case cacheTokenRateModeMixed:
|
||||
return cacheTokenRateModeMixed
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func cachedTokenRateModeByRelayFormat(relayFormat types.RelayFormat) string {
|
||||
switch relayFormat {
|
||||
case types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, types.RelayFormatOpenAIResponsesCompaction:
|
||||
return cacheTokenRateModeCachedOverPrompt
|
||||
case types.RelayFormatClaude:
|
||||
return cacheTokenRateModeCachedOverPromptPlusCached
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFp string) string {
|
||||
ruleName = strings.TrimSpace(ruleName)
|
||||
usingGroup = strings.TrimSpace(usingGroup)
|
||||
keyFp = strings.TrimSpace(keyFp)
|
||||
if ruleName == "" || keyFp == "" {
|
||||
return ""
|
||||
}
|
||||
return ruleName + "\n" + usingGroup + "\n" + keyFp
|
||||
}
|
||||
|
||||
func usageCacheSignals(usage *dto.Usage) (hit bool, cachedTokens int64, promptCacheHitTokens int64) {
|
||||
if usage == nil {
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
cached := int64(0)
|
||||
if usage.PromptTokensDetails.CachedTokens > 0 {
|
||||
cached = int64(usage.PromptTokensDetails.CachedTokens)
|
||||
} else if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
|
||||
cached = int64(usage.InputTokensDetails.CachedTokens)
|
||||
}
|
||||
pcht := int64(0)
|
||||
if usage.PromptCacheHitTokens > 0 {
|
||||
pcht = int64(usage.PromptCacheHitTokens)
|
||||
}
|
||||
return cached > 0 || pcht > 0, cached, pcht
|
||||
}
|
||||
|
||||
func usagePromptTokens(usage *dto.Usage) int {
|
||||
if usage == nil {
|
||||
return 0
|
||||
}
|
||||
if usage.PromptTokens > 0 {
|
||||
return usage.PromptTokens
|
||||
}
|
||||
return usage.InputTokens
|
||||
}
|
||||
|
||||
func usageCompletionTokens(usage *dto.Usage) int {
|
||||
if usage == nil {
|
||||
return 0
|
||||
}
|
||||
if usage.CompletionTokens > 0 {
|
||||
return usage.CompletionTokens
|
||||
}
|
||||
return usage.OutputTokens
|
||||
}
|
||||
|
||||
func usageTotalTokens(usage *dto.Usage) int {
|
||||
if usage == nil {
|
||||
return 0
|
||||
}
|
||||
if usage.TotalTokens > 0 {
|
||||
return usage.TotalTokens
|
||||
}
|
||||
pt := usagePromptTokens(usage)
|
||||
ct := usageCompletionTokens(usage)
|
||||
if pt > 0 || ct > 0 {
|
||||
return pt + ct
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getChannelAffinityUsageCacheStatsCache() *cachex.HybridCache[ChannelAffinityUsageCacheCounters] {
|
||||
channelAffinityUsageCacheStatsOnce.Do(func() {
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
capacity := 100_000
|
||||
defaultTTLSeconds := 3600
|
||||
if setting != nil {
|
||||
if setting.MaxEntries > 0 {
|
||||
capacity = setting.MaxEntries
|
||||
}
|
||||
if setting.DefaultTTLSeconds > 0 {
|
||||
defaultTTLSeconds = setting.DefaultTTLSeconds
|
||||
}
|
||||
}
|
||||
|
||||
channelAffinityUsageCacheStatsCache = cachex.NewHybridCache[ChannelAffinityUsageCacheCounters](cachex.HybridCacheConfig[ChannelAffinityUsageCacheCounters]{
|
||||
Namespace: cachex.Namespace(channelAffinityUsageCacheStatsNamespace),
|
||||
Redis: common.RDB,
|
||||
RedisEnabled: func() bool {
|
||||
return common.RedisEnabled && common.RDB != nil
|
||||
},
|
||||
RedisCodec: cachex.JSONCodec[ChannelAffinityUsageCacheCounters]{},
|
||||
Memory: func() *hot.HotCache[string, ChannelAffinityUsageCacheCounters] {
|
||||
return hot.NewHotCache[string, ChannelAffinityUsageCacheCounters](hot.LRU, capacity).
|
||||
WithTTL(time.Duration(defaultTTLSeconds) * time.Second).
|
||||
WithJanitor().
|
||||
Build()
|
||||
},
|
||||
})
|
||||
})
|
||||
return channelAffinityUsageCacheStatsCache
|
||||
}
|
||||
|
||||
func channelAffinityUsageCacheStatsLock(key string) *sync.Mutex {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(key))
|
||||
idx := h.Sum32() % uint32(len(channelAffinityUsageCacheStatsLocks))
|
||||
return &channelAffinityUsageCacheStatsLocks[idx]
|
||||
}
|
||||
247
service/channel_affinity_template_test.go
Normal file
247
service/channel_affinity_template_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func buildChannelAffinityTemplateContextForTest(meta channelAffinityMeta) *gin.Context {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
setChannelAffinityContext(ctx, meta)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestApplyChannelAffinityOverrideTemplate_NoTemplate(t *testing.T) {
|
||||
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
|
||||
RuleName: "rule-no-template",
|
||||
})
|
||||
base := map[string]interface{}{
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
|
||||
require.False(t, applied)
|
||||
require.Equal(t, base, merged)
|
||||
}
|
||||
|
||||
func TestApplyChannelAffinityOverrideTemplate_MergeTemplate(t *testing.T) {
|
||||
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
|
||||
RuleName: "rule-with-template",
|
||||
ParamTemplate: map[string]interface{}{
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.95,
|
||||
},
|
||||
UsingGroup: "default",
|
||||
ModelName: "gpt-4.1",
|
||||
RequestPath: "/v1/responses",
|
||||
KeySourceType: "gjson",
|
||||
KeySourcePath: "prompt_cache_key",
|
||||
KeyHint: "abcd...wxyz",
|
||||
KeyFingerprint: "abcd1234",
|
||||
})
|
||||
base := map[string]interface{}{
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2000,
|
||||
}
|
||||
|
||||
merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
|
||||
require.True(t, applied)
|
||||
require.Equal(t, 0.7, merged["temperature"])
|
||||
require.Equal(t, 0.95, merged["top_p"])
|
||||
require.Equal(t, 2000, merged["max_tokens"])
|
||||
require.Equal(t, 0.7, base["temperature"])
|
||||
|
||||
anyInfo, ok := ctx.Get(ginKeyChannelAffinityLogInfo)
|
||||
require.True(t, ok)
|
||||
info, ok := anyInfo.(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
overrideInfoAny, ok := info["override_template"]
|
||||
require.True(t, ok)
|
||||
overrideInfo, ok := overrideInfoAny.(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, overrideInfo["applied"])
|
||||
require.Equal(t, "rule-with-template", overrideInfo["rule_name"])
|
||||
require.EqualValues(t, 2, overrideInfo["param_override_keys"])
|
||||
}
|
||||
|
||||
func TestApplyChannelAffinityOverrideTemplate_MergeOperations(t *testing.T) {
|
||||
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
|
||||
RuleName: "rule-with-ops-template",
|
||||
ParamTemplate: map[string]interface{}{
|
||||
"operations": []map[string]interface{}{
|
||||
{
|
||||
"mode": "pass_headers",
|
||||
"value": []string{"Originator"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
base := map[string]interface{}{
|
||||
"temperature": 0.7,
|
||||
"operations": []map[string]interface{}{
|
||||
{
|
||||
"path": "model",
|
||||
"mode": "trim_prefix",
|
||||
"value": "openai/",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
|
||||
require.True(t, applied)
|
||||
require.Equal(t, 0.7, merged["temperature"])
|
||||
|
||||
opsAny, ok := merged["operations"]
|
||||
require.True(t, ok)
|
||||
ops, ok := opsAny.([]interface{})
|
||||
require.True(t, ok)
|
||||
require.Len(t, ops, 2)
|
||||
|
||||
firstOp, ok := ops[0].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "pass_headers", firstOp["mode"])
|
||||
|
||||
secondOp, ok := ops[1].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "trim_prefix", secondOp["mode"])
|
||||
}
|
||||
|
||||
func TestShouldSkipRetryAfterChannelAffinityFailure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx func() *gin.Context
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil context",
|
||||
ctx: func() *gin.Context {
|
||||
return nil
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "explicit skip retry flag in context",
|
||||
ctx: func() *gin.Context {
|
||||
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
|
||||
RuleName: "rule-explicit-flag",
|
||||
SkipRetry: false,
|
||||
UsingGroup: "default",
|
||||
ModelName: "gpt-5",
|
||||
})
|
||||
ctx.Set(ginKeyChannelAffinitySkipRetry, true)
|
||||
return ctx
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "fallback to matched rule meta",
|
||||
ctx: func() *gin.Context {
|
||||
return buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
|
||||
RuleName: "rule-skip-retry",
|
||||
SkipRetry: true,
|
||||
UsingGroup: "default",
|
||||
ModelName: "gpt-5",
|
||||
})
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no flag and no skip retry meta",
|
||||
ctx: func() *gin.Context {
|
||||
return buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
|
||||
RuleName: "rule-no-skip-retry",
|
||||
SkipRetry: false,
|
||||
UsingGroup: "default",
|
||||
ModelName: "gpt-5",
|
||||
})
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, ShouldSkipRetryAfterChannelAffinityFailure(tt.ctx()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
require.NotNil(t, setting)
|
||||
|
||||
var codexRule *operation_setting.ChannelAffinityRule
|
||||
for i := range setting.Rules {
|
||||
rule := &setting.Rules[i]
|
||||
if strings.EqualFold(strings.TrimSpace(rule.Name), "codex cli trace") {
|
||||
codexRule = rule
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, codexRule)
|
||||
|
||||
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
|
||||
|
||||
cache := getChannelAffinityCache()
|
||||
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
|
||||
t.Cleanup(func() {
|
||||
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(fmt.Sprintf(`{"prompt_cache_key":"%s"}`, affinityValue)))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5", "default")
|
||||
require.True(t, found)
|
||||
require.Equal(t, 9527, channelID)
|
||||
|
||||
baseOverride := map[string]interface{}{
|
||||
"temperature": 0.2,
|
||||
}
|
||||
mergedOverride, applied := ApplyChannelAffinityOverrideTemplate(ctx, baseOverride)
|
||||
require.True(t, applied)
|
||||
require.Equal(t, 0.2, mergedOverride["temperature"])
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RequestHeaders: map[string]string{
|
||||
"Originator": "Codex CLI",
|
||||
"Session_id": "sess-123",
|
||||
"User-Agent": "codex-cli-test",
|
||||
},
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ParamOverride: mergedOverride,
|
||||
HeadersOverride: map[string]interface{}{
|
||||
"X-Static": "legacy-static",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := relaycommon.ApplyParamOverrideWithRelayInfo([]byte(`{"model":"gpt-5"}`), info)
|
||||
require.NoError(t, err)
|
||||
require.True(t, info.UseRuntimeHeadersOverride)
|
||||
|
||||
require.Equal(t, "legacy-static", info.RuntimeHeadersOverride["x-static"])
|
||||
require.Equal(t, "Codex CLI", info.RuntimeHeadersOverride["originator"])
|
||||
require.Equal(t, "sess-123", info.RuntimeHeadersOverride["session_id"])
|
||||
require.Equal(t, "codex-cli-test", info.RuntimeHeadersOverride["user-agent"])
|
||||
|
||||
_, exists := info.RuntimeHeadersOverride["x-codex-beta-features"]
|
||||
require.False(t, exists)
|
||||
_, exists = info.RuntimeHeadersOverride["x-codex-turn-metadata"]
|
||||
require.False(t, exists)
|
||||
}
|
||||
105
service/channel_affinity_usage_cache_test.go
Normal file
105
service/channel_affinity_usage_cache_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) *gin.Context {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
setChannelAffinityContext(ctx, channelAffinityMeta{
|
||||
CacheKey: fmt.Sprintf("test:%s:%s:%s", ruleName, usingGroup, keyFP),
|
||||
TTLSeconds: 600,
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFP,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) {
|
||||
ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
|
||||
usingGroup := "default"
|
||||
keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
|
||||
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 40,
|
||||
TotalTokens: 140,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, types.RelayFormatClaude)
|
||||
stats := GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFP)
|
||||
|
||||
require.EqualValues(t, 1, stats.Total)
|
||||
require.EqualValues(t, 1, stats.Hit)
|
||||
require.EqualValues(t, 100, stats.PromptTokens)
|
||||
require.EqualValues(t, 40, stats.CompletionTokens)
|
||||
require.EqualValues(t, 140, stats.TotalTokens)
|
||||
require.EqualValues(t, 30, stats.CachedTokens)
|
||||
require.Equal(t, cacheTokenRateModeCachedOverPromptPlusCached, stats.CachedTokenRateMode)
|
||||
}
|
||||
|
||||
func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) {
|
||||
ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
|
||||
usingGroup := "default"
|
||||
keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
|
||||
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
|
||||
|
||||
openAIUsage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 10,
|
||||
},
|
||||
}
|
||||
claudeUsage := &dto.Usage{
|
||||
PromptTokens: 80,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 20,
|
||||
},
|
||||
}
|
||||
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, openAIUsage, types.RelayFormatOpenAI)
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, claudeUsage, types.RelayFormatClaude)
|
||||
stats := GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFP)
|
||||
|
||||
require.EqualValues(t, 2, stats.Total)
|
||||
require.EqualValues(t, 2, stats.Hit)
|
||||
require.EqualValues(t, 180, stats.PromptTokens)
|
||||
require.EqualValues(t, 30, stats.CachedTokens)
|
||||
require.Equal(t, cacheTokenRateModeMixed, stats.CachedTokenRateMode)
|
||||
}
|
||||
|
||||
func TestObserveChannelAffinityUsageCacheByRelayFormat_UnsupportedModeKeepsEmpty(t *testing.T) {
|
||||
ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
|
||||
usingGroup := "default"
|
||||
keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
|
||||
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 25,
|
||||
},
|
||||
}
|
||||
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, types.RelayFormatGemini)
|
||||
stats := GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFP)
|
||||
|
||||
require.EqualValues(t, 1, stats.Total)
|
||||
require.EqualValues(t, 1, stats.Hit)
|
||||
require.EqualValues(t, 25, stats.CachedTokens)
|
||||
require.Equal(t, "", stats.CachedTokenRateMode)
|
||||
}
|
||||
162
service/channel_select.go
Normal file
162
service/channel_select.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"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/setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RetryParam struct {
|
||||
Ctx *gin.Context
|
||||
TokenGroup string
|
||||
ModelName string
|
||||
Retry *int
|
||||
resetNextTry bool
|
||||
}
|
||||
|
||||
func (p *RetryParam) GetRetry() int {
|
||||
if p.Retry == nil {
|
||||
return 0
|
||||
}
|
||||
return *p.Retry
|
||||
}
|
||||
|
||||
func (p *RetryParam) SetRetry(retry int) {
|
||||
p.Retry = &retry
|
||||
}
|
||||
|
||||
func (p *RetryParam) IncreaseRetry() {
|
||||
if p.resetNextTry {
|
||||
p.resetNextTry = false
|
||||
return
|
||||
}
|
||||
if p.Retry == nil {
|
||||
p.Retry = new(int)
|
||||
}
|
||||
*p.Retry++
|
||||
}
|
||||
|
||||
func (p *RetryParam) ResetRetryNextTry() {
|
||||
p.resetNextTry = true
|
||||
}
|
||||
|
||||
// CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements.
|
||||
// 尝试获取一个满足要求的随机渠道。
|
||||
//
|
||||
// For "auto" tokenGroup with cross-group Retry enabled:
|
||||
// 对于启用了跨分组重试的 "auto" tokenGroup:
|
||||
//
|
||||
// - Each group will exhaust all its priorities before moving to the next group.
|
||||
// 每个分组会用完所有优先级后才会切换到下一个分组。
|
||||
//
|
||||
// - Uses ContextKeyAutoGroupIndex to track current group index.
|
||||
// 使用 ContextKeyAutoGroupIndex 跟踪当前分组索引。
|
||||
//
|
||||
// - Uses ContextKeyAutoGroupRetryIndex to track the global Retry count when current group started.
|
||||
// 使用 ContextKeyAutoGroupRetryIndex 跟踪当前分组开始时的全局重试次数。
|
||||
//
|
||||
// - priorityRetry = Retry - startRetryIndex, represents the priority level within current group.
|
||||
// priorityRetry = Retry - startRetryIndex,表示当前分组内的优先级级别。
|
||||
//
|
||||
// - When GetRandomSatisfiedChannel returns nil (priorities exhausted), moves to next group.
|
||||
// 当 GetRandomSatisfiedChannel 返回 nil(优先级用完)时,切换到下一个分组。
|
||||
//
|
||||
// Example flow (2 groups, each with 2 priorities, RetryTimes=3):
|
||||
// 示例流程(2个分组,每个有2个优先级,RetryTimes=3):
|
||||
//
|
||||
// Retry=0: GroupA, priority0 (startRetryIndex=0, priorityRetry=0)
|
||||
// 分组A, 优先级0
|
||||
//
|
||||
// Retry=1: GroupA, priority1 (startRetryIndex=0, priorityRetry=1)
|
||||
// 分组A, 优先级1
|
||||
//
|
||||
// Retry=2: GroupA exhausted → GroupB, priority0 (startRetryIndex=2, priorityRetry=0)
|
||||
// 分组A用完 → 分组B, 优先级0
|
||||
//
|
||||
// Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1)
|
||||
// 分组B, 优先级1
|
||||
func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, error) {
|
||||
var channel *model.Channel
|
||||
var err error
|
||||
selectGroup := param.TokenGroup
|
||||
userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup)
|
||||
|
||||
if param.TokenGroup == "auto" {
|
||||
if len(setting.GetAutoGroups()) == 0 {
|
||||
return nil, selectGroup, errors.New("auto groups is not enabled")
|
||||
}
|
||||
autoGroups := GetUserAutoGroup(userGroup)
|
||||
|
||||
// startGroupIndex: the group index to start searching from
|
||||
// startGroupIndex: 开始搜索的分组索引
|
||||
startGroupIndex := 0
|
||||
crossGroupRetry := common.GetContextKeyBool(param.Ctx, constant.ContextKeyTokenCrossGroupRetry)
|
||||
|
||||
if lastGroupIndex, exists := common.GetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex); exists {
|
||||
if idx, ok := lastGroupIndex.(int); ok {
|
||||
startGroupIndex = idx
|
||||
}
|
||||
}
|
||||
|
||||
for i := startGroupIndex; i < len(autoGroups); i++ {
|
||||
autoGroup := autoGroups[i]
|
||||
// Calculate priorityRetry for current group
|
||||
// 计算当前分组的 priorityRetry
|
||||
priorityRetry := param.GetRetry()
|
||||
// If moved to a new group, reset priorityRetry and update startRetryIndex
|
||||
// 如果切换到新分组,重置 priorityRetry 并更新 startRetryIndex
|
||||
if i > startGroupIndex {
|
||||
priorityRetry = 0
|
||||
}
|
||||
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
|
||||
|
||||
channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry)
|
||||
if channel == nil {
|
||||
// Current group has no available channel for this model, try next group
|
||||
// 当前分组没有该模型的可用渠道,尝试下一个分组
|
||||
logger.LogDebug(param.Ctx, "No available channel in group %s for model %s at priorityRetry %d, trying next group", autoGroup, param.ModelName, priorityRetry)
|
||||
// 重置状态以尝试下一个分组
|
||||
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
|
||||
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, 0)
|
||||
// Reset retry counter so outer loop can continue for next group
|
||||
// 重置重试计数器,以便外层循环可以为下一个分组继续
|
||||
param.SetRetry(0)
|
||||
continue
|
||||
}
|
||||
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, autoGroup)
|
||||
selectGroup = autoGroup
|
||||
logger.LogDebug(param.Ctx, "Auto selected group: %s", autoGroup)
|
||||
|
||||
// Prepare state for next retry
|
||||
// 为下一次重试准备状态
|
||||
if crossGroupRetry && priorityRetry >= common.RetryTimes {
|
||||
// Current group has exhausted all retries, prepare to switch to next group
|
||||
// This request still uses current group, but next retry will use next group
|
||||
// 当前分组已用完所有重试次数,准备切换到下一个分组
|
||||
// 本次请求仍使用当前分组,但下次重试将使用下一个分组
|
||||
logger.LogDebug(param.Ctx, "Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry", autoGroup, priorityRetry, common.RetryTimes)
|
||||
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
|
||||
// Reset retry counter so outer loop can continue for next group
|
||||
// 重置重试计数器,以便外层循环可以为下一个分组继续
|
||||
param.SetRetry(0)
|
||||
param.ResetRetryNextTry()
|
||||
} else {
|
||||
// Stay in current group, save current state
|
||||
// 保持在当前分组,保存当前状态
|
||||
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i)
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry())
|
||||
if err != nil {
|
||||
return nil, param.TokenGroup, err
|
||||
}
|
||||
}
|
||||
return channel, selectGroup, nil
|
||||
}
|
||||
104
service/codex_credential_refresh.go
Normal file
104
service/codex_credential_refresh.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
)
|
||||
|
||||
type CodexCredentialRefreshOptions struct {
|
||||
ResetCaches bool
|
||||
}
|
||||
|
||||
type CodexOAuthKey struct {
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
LastRefresh string `json:"last_refresh,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Expired string `json:"expired,omitempty"`
|
||||
}
|
||||
|
||||
func parseCodexOAuthKey(raw string) (*CodexOAuthKey, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, errors.New("codex channel: empty oauth key")
|
||||
}
|
||||
var key CodexOAuthKey
|
||||
if err := common.Unmarshal([]byte(raw), &key); err != nil {
|
||||
return nil, errors.New("codex channel: invalid oauth key json")
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts CodexCredentialRefreshOptions) (*CodexOAuthKey, *model.Channel, error) {
|
||||
ch, err := model.GetChannelById(channelID, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if ch == nil {
|
||||
return nil, nil, fmt.Errorf("channel not found")
|
||||
}
|
||||
if ch.Type != constant.ChannelTypeCodex {
|
||||
return nil, nil, fmt.Errorf("channel type is not Codex")
|
||||
}
|
||||
|
||||
oauthKey, err := parseCodexOAuthKey(strings.TrimSpace(ch.Key))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if strings.TrimSpace(oauthKey.RefreshToken) == "" {
|
||||
return nil, nil, fmt.Errorf("codex channel: refresh_token is required to refresh credential")
|
||||
}
|
||||
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, ch.GetSetting().Proxy)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
oauthKey.AccessToken = res.AccessToken
|
||||
oauthKey.RefreshToken = res.RefreshToken
|
||||
oauthKey.LastRefresh = time.Now().Format(time.RFC3339)
|
||||
oauthKey.Expired = res.ExpiresAt.Format(time.RFC3339)
|
||||
if strings.TrimSpace(oauthKey.Type) == "" {
|
||||
oauthKey.Type = "codex"
|
||||
}
|
||||
|
||||
if strings.TrimSpace(oauthKey.AccountID) == "" {
|
||||
if accountID, ok := ExtractCodexAccountIDFromJWT(oauthKey.AccessToken); ok {
|
||||
oauthKey.AccountID = accountID
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(oauthKey.Email) == "" {
|
||||
if email, ok := ExtractEmailFromJWT(oauthKey.AccessToken); ok {
|
||||
oauthKey.Email = email
|
||||
}
|
||||
}
|
||||
|
||||
encoded, err := common.Marshal(oauthKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if opts.ResetCaches {
|
||||
model.InitChannelCache()
|
||||
ResetProxyClientCache()
|
||||
}
|
||||
|
||||
return oauthKey, ch, nil
|
||||
}
|
||||
140
service/codex_credential_refresh_task.go
Normal file
140
service/codex_credential_refresh_task.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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/bytedance/gopkg/util/gopool"
|
||||
)
|
||||
|
||||
const (
|
||||
codexCredentialRefreshTickInterval = 10 * time.Minute
|
||||
codexCredentialRefreshThreshold = 24 * time.Hour
|
||||
codexCredentialRefreshBatchSize = 200
|
||||
codexCredentialRefreshTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
codexCredentialRefreshOnce sync.Once
|
||||
codexCredentialRefreshRunning atomic.Bool
|
||||
)
|
||||
|
||||
func StartCodexCredentialAutoRefreshTask() {
|
||||
codexCredentialRefreshOnce.Do(func() {
|
||||
if !common.IsMasterNode {
|
||||
return
|
||||
}
|
||||
|
||||
gopool.Go(func() {
|
||||
logger.LogInfo(context.Background(), fmt.Sprintf("codex credential auto-refresh task started: tick=%s threshold=%s", codexCredentialRefreshTickInterval, codexCredentialRefreshThreshold))
|
||||
|
||||
ticker := time.NewTicker(codexCredentialRefreshTickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
runCodexCredentialAutoRefreshOnce()
|
||||
for range ticker.C {
|
||||
runCodexCredentialAutoRefreshOnce()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func runCodexCredentialAutoRefreshOnce() {
|
||||
if !codexCredentialRefreshRunning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer codexCredentialRefreshRunning.Store(false)
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
var refreshed int
|
||||
var scanned int
|
||||
|
||||
offset := 0
|
||||
for {
|
||||
var channels []*model.Channel
|
||||
err := model.DB.
|
||||
Select("id", "name", "key", "status", "channel_info").
|
||||
Where("type = ? AND status = 1", constant.ChannelTypeCodex).
|
||||
Order("id asc").
|
||||
Limit(codexCredentialRefreshBatchSize).
|
||||
Offset(offset).
|
||||
Find(&channels).Error
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("codex credential auto-refresh: query channels failed: %v", err))
|
||||
return
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
break
|
||||
}
|
||||
offset += codexCredentialRefreshBatchSize
|
||||
|
||||
for _, ch := range channels {
|
||||
if ch == nil {
|
||||
continue
|
||||
}
|
||||
scanned++
|
||||
if ch.ChannelInfo.IsMultiKey {
|
||||
continue
|
||||
}
|
||||
|
||||
rawKey := strings.TrimSpace(ch.Key)
|
||||
if rawKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
oauthKey, err := parseCodexOAuthKey(rawKey)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
refreshToken := strings.TrimSpace(oauthKey.RefreshToken)
|
||||
if refreshToken == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
expiredAtRaw := strings.TrimSpace(oauthKey.Expired)
|
||||
expiredAt, err := time.Parse(time.RFC3339, expiredAtRaw)
|
||||
if err == nil && !expiredAt.IsZero() && expiredAt.Sub(now) > codexCredentialRefreshThreshold {
|
||||
continue
|
||||
}
|
||||
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, codexCredentialRefreshTimeout)
|
||||
newKey, _, err := RefreshCodexChannelCredential(refreshCtx, ch.Id, CodexCredentialRefreshOptions{ResetCaches: false})
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("codex credential auto-refresh: channel_id=%d name=%s refresh failed: %v", ch.Id, ch.Name, err))
|
||||
continue
|
||||
}
|
||||
|
||||
refreshed++
|
||||
logger.LogInfo(ctx, fmt.Sprintf("codex credential auto-refresh: channel_id=%d name=%s refreshed, expires_at=%s", ch.Id, ch.Name, newKey.Expired))
|
||||
}
|
||||
}
|
||||
|
||||
if refreshed > 0 {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("codex credential auto-refresh: InitChannelCache panic: %v", r))
|
||||
}
|
||||
}()
|
||||
model.InitChannelCache()
|
||||
}()
|
||||
ResetProxyClientCache()
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(ctx, "codex credential auto-refresh: scanned=%d refreshed=%d", scanned, refreshed)
|
||||
}
|
||||
}
|
||||
317
service/codex_oauth.go
Normal file
317
service/codex_oauth.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
const (
|
||||
codexOAuthClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
codexOAuthAuthorizeURL = "https://auth.openai.com/oauth/authorize"
|
||||
codexOAuthTokenURL = "https://auth.openai.com/oauth/token"
|
||||
codexOAuthRedirectURI = "http://localhost:1455/auth/callback"
|
||||
codexOAuthScope = "openid profile email offline_access"
|
||||
codexJWTClaimPath = "https://api.openai.com/auth"
|
||||
defaultHTTPTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
type CodexOAuthTokenResult struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type CodexOAuthAuthorizationFlow struct {
|
||||
State string
|
||||
Verifier string
|
||||
Challenge string
|
||||
AuthorizeURL string
|
||||
}
|
||||
|
||||
func RefreshCodexOAuthToken(ctx context.Context, refreshToken string) (*CodexOAuthTokenResult, error) {
|
||||
return RefreshCodexOAuthTokenWithProxy(ctx, refreshToken, "")
|
||||
}
|
||||
|
||||
func RefreshCodexOAuthTokenWithProxy(ctx context.Context, refreshToken string, proxyURL string) (*CodexOAuthTokenResult, error) {
|
||||
client, err := getCodexOAuthHTTPClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return refreshCodexOAuthToken(ctx, client, codexOAuthTokenURL, codexOAuthClientID, refreshToken)
|
||||
}
|
||||
|
||||
func ExchangeCodexAuthorizationCode(ctx context.Context, code string, verifier string) (*CodexOAuthTokenResult, error) {
|
||||
return ExchangeCodexAuthorizationCodeWithProxy(ctx, code, verifier, "")
|
||||
}
|
||||
|
||||
func ExchangeCodexAuthorizationCodeWithProxy(ctx context.Context, code string, verifier string, proxyURL string) (*CodexOAuthTokenResult, error) {
|
||||
client, err := getCodexOAuthHTTPClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exchangeCodexAuthorizationCode(ctx, client, codexOAuthTokenURL, codexOAuthClientID, code, verifier, codexOAuthRedirectURI)
|
||||
}
|
||||
|
||||
func CreateCodexOAuthAuthorizationFlow() (*CodexOAuthAuthorizationFlow, error) {
|
||||
state, err := createStateHex(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifier, challenge, err := generatePKCEPair()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := buildCodexAuthorizeURL(state, challenge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CodexOAuthAuthorizationFlow{
|
||||
State: state,
|
||||
Verifier: verifier,
|
||||
Challenge: challenge,
|
||||
AuthorizeURL: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func refreshCodexOAuthToken(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
tokenURL string,
|
||||
clientID string,
|
||||
refreshToken string,
|
||||
) (*CodexOAuthTokenResult, error) {
|
||||
rt := strings.TrimSpace(refreshToken)
|
||||
if rt == "" {
|
||||
return nil, errors.New("empty refresh_token")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", rt)
|
||||
form.Set("client_id", clientID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var payload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err := common.DecodeJson(resp.Body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("codex oauth refresh failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" || payload.ExpiresIn <= 0 {
|
||||
return nil, errors.New("codex oauth refresh response missing fields")
|
||||
}
|
||||
|
||||
return &CodexOAuthTokenResult{
|
||||
AccessToken: strings.TrimSpace(payload.AccessToken),
|
||||
RefreshToken: strings.TrimSpace(payload.RefreshToken),
|
||||
ExpiresAt: time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func exchangeCodexAuthorizationCode(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
tokenURL string,
|
||||
clientID string,
|
||||
code string,
|
||||
verifier string,
|
||||
redirectURI string,
|
||||
) (*CodexOAuthTokenResult, error) {
|
||||
c := strings.TrimSpace(code)
|
||||
v := strings.TrimSpace(verifier)
|
||||
if c == "" {
|
||||
return nil, errors.New("empty authorization code")
|
||||
}
|
||||
if v == "" {
|
||||
return nil, errors.New("empty code_verifier")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("client_id", clientID)
|
||||
form.Set("code", c)
|
||||
form.Set("code_verifier", v)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var payload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := common.DecodeJson(resp.Body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("codex oauth code exchange failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" || payload.ExpiresIn <= 0 {
|
||||
return nil, errors.New("codex oauth token response missing fields")
|
||||
}
|
||||
return &CodexOAuthTokenResult{
|
||||
AccessToken: strings.TrimSpace(payload.AccessToken),
|
||||
RefreshToken: strings.TrimSpace(payload.RefreshToken),
|
||||
ExpiresAt: time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getCodexOAuthHTTPClient(proxyURL string) (*http.Client, error) {
|
||||
baseClient, err := GetHttpClientWithProxy(strings.TrimSpace(proxyURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if baseClient == nil {
|
||||
return &http.Client{Timeout: defaultHTTPTimeout}, nil
|
||||
}
|
||||
clientCopy := *baseClient
|
||||
clientCopy.Timeout = defaultHTTPTimeout
|
||||
return &clientCopy, nil
|
||||
}
|
||||
|
||||
func buildCodexAuthorizeURL(state string, challenge string) (string, error) {
|
||||
u, err := url.Parse(codexOAuthAuthorizeURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", codexOAuthClientID)
|
||||
q.Set("redirect_uri", codexOAuthRedirectURI)
|
||||
q.Set("scope", codexOAuthScope)
|
||||
q.Set("code_challenge", challenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
q.Set("state", state)
|
||||
q.Set("id_token_add_organizations", "true")
|
||||
q.Set("codex_cli_simplified_flow", "true")
|
||||
q.Set("originator", "codex_cli_rs")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func createStateHex(nBytes int) (string, error) {
|
||||
if nBytes <= 0 {
|
||||
return "", errors.New("invalid state bytes length")
|
||||
}
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", b), nil
|
||||
}
|
||||
|
||||
func generatePKCEPair() (verifier string, challenge string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
verifier = base64.RawURLEncoding.EncodeToString(b)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return verifier, challenge, nil
|
||||
}
|
||||
|
||||
func ExtractCodexAccountIDFromJWT(token string) (string, bool) {
|
||||
claims, ok := decodeJWTClaims(token)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
raw, ok := claims[codexJWTClaimPath]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
v, ok := obj["chatgpt_account_id"]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
func ExtractEmailFromJWT(token string) (string, bool) {
|
||||
claims, ok := decodeJWTClaims(token)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
v, ok := claims["email"]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
func decodeJWTClaims(token string) (map[string]any, bool) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, false
|
||||
}
|
||||
payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payloadRaw, &claims); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
56
service/codex_wham_usage.go
Normal file
56
service/codex_wham_usage.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FetchCodexWhamUsage(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
baseURL string,
|
||||
accessToken string,
|
||||
accountID string,
|
||||
) (statusCode int, body []byte, err error) {
|
||||
if client == nil {
|
||||
return 0, nil, fmt.Errorf("nil http client")
|
||||
}
|
||||
bu := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if bu == "" {
|
||||
return 0, nil, fmt.Errorf("empty baseURL")
|
||||
}
|
||||
at := strings.TrimSpace(accessToken)
|
||||
aid := strings.TrimSpace(accountID)
|
||||
if at == "" {
|
||||
return 0, nil, fmt.Errorf("empty accessToken")
|
||||
}
|
||||
if aid == "" {
|
||||
return 0, nil, fmt.Errorf("empty accountID")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bu+"/backend-api/wham/usage", nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+at)
|
||||
req.Header.Set("chatgpt-account-id", aid)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if req.Header.Get("originator") == "" {
|
||||
req.Header.Set("originator", "codex_cli_rs")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp.StatusCode, nil, err
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
988
service/convert.go
Normal file
988
service/convert.go
Normal file
@@ -0,0 +1,988 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/channel/openrouter"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/reasonmap"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||
openAIRequest := dto.GeneralOpenAIRequest{
|
||||
Model: claudeRequest.Model,
|
||||
Temperature: claudeRequest.Temperature,
|
||||
}
|
||||
if claudeRequest.MaxTokens != nil {
|
||||
openAIRequest.MaxTokens = lo.ToPtr(lo.FromPtr(claudeRequest.MaxTokens))
|
||||
}
|
||||
if claudeRequest.TopP != nil {
|
||||
openAIRequest.TopP = lo.ToPtr(lo.FromPtr(claudeRequest.TopP))
|
||||
}
|
||||
if claudeRequest.TopK != nil {
|
||||
openAIRequest.TopK = lo.ToPtr(lo.FromPtr(claudeRequest.TopK))
|
||||
}
|
||||
if claudeRequest.Stream != nil {
|
||||
openAIRequest.Stream = lo.ToPtr(lo.FromPtr(claudeRequest.Stream))
|
||||
}
|
||||
|
||||
isOpenRouter := info.ChannelType == constant.ChannelTypeOpenRouter
|
||||
|
||||
if isOpenRouter {
|
||||
if effort := claudeRequest.GetEfforts(); effort != "" {
|
||||
effortBytes, _ := json.Marshal(effort)
|
||||
openAIRequest.Verbosity = effortBytes
|
||||
}
|
||||
if claudeRequest.Thinking != nil {
|
||||
var reasoning openrouter.RequestReasoning
|
||||
if claudeRequest.Thinking.Type == "enabled" {
|
||||
reasoning = openrouter.RequestReasoning{
|
||||
Enabled: true,
|
||||
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
|
||||
}
|
||||
} else if claudeRequest.Thinking.Type == "adaptive" {
|
||||
reasoning = openrouter.RequestReasoning{
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
reasoningJSON, err := json.Marshal(reasoning)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
|
||||
}
|
||||
openAIRequest.Reasoning = reasoningJSON
|
||||
}
|
||||
} else {
|
||||
thinkingSuffix := "-thinking"
|
||||
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
|
||||
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
|
||||
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
|
||||
}
|
||||
}
|
||||
|
||||
// Convert stop sequences
|
||||
if len(claudeRequest.StopSequences) == 1 {
|
||||
openAIRequest.Stop = claudeRequest.StopSequences[0]
|
||||
} else if len(claudeRequest.StopSequences) > 1 {
|
||||
openAIRequest.Stop = claudeRequest.StopSequences
|
||||
}
|
||||
|
||||
// Convert tools
|
||||
tools, _ := common.Any2Type[[]dto.Tool](claudeRequest.Tools)
|
||||
openAITools := make([]dto.ToolCallRequest, 0)
|
||||
for _, claudeTool := range tools {
|
||||
openAITool := dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: claudeTool.Name,
|
||||
Description: claudeTool.Description,
|
||||
Parameters: claudeTool.InputSchema,
|
||||
},
|
||||
}
|
||||
openAITools = append(openAITools, openAITool)
|
||||
}
|
||||
openAIRequest.Tools = openAITools
|
||||
|
||||
// Convert messages
|
||||
openAIMessages := make([]dto.Message, 0)
|
||||
|
||||
// Add system message if present
|
||||
if claudeRequest.System != nil {
|
||||
if claudeRequest.IsStringSystem() && claudeRequest.GetStringSystem() != "" {
|
||||
openAIMessage := dto.Message{
|
||||
Role: "system",
|
||||
}
|
||||
openAIMessage.SetStringContent(claudeRequest.GetStringSystem())
|
||||
openAIMessages = append(openAIMessages, openAIMessage)
|
||||
} else {
|
||||
systems := claudeRequest.ParseSystem()
|
||||
if len(systems) > 0 {
|
||||
openAIMessage := dto.Message{
|
||||
Role: "system",
|
||||
}
|
||||
isOpenRouterClaude := isOpenRouter && strings.HasPrefix(info.UpstreamModelName, "anthropic/claude")
|
||||
if isOpenRouterClaude {
|
||||
systemMediaMessages := make([]dto.MediaContent, 0, len(systems))
|
||||
for _, system := range systems {
|
||||
message := dto.MediaContent{
|
||||
Type: "text",
|
||||
Text: system.GetText(),
|
||||
CacheControl: system.CacheControl,
|
||||
}
|
||||
systemMediaMessages = append(systemMediaMessages, message)
|
||||
}
|
||||
openAIMessage.SetMediaContent(systemMediaMessages)
|
||||
} else {
|
||||
systemStr := ""
|
||||
for _, system := range systems {
|
||||
if system.Text != nil {
|
||||
systemStr += *system.Text
|
||||
}
|
||||
}
|
||||
openAIMessage.SetStringContent(systemStr)
|
||||
}
|
||||
openAIMessages = append(openAIMessages, openAIMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, claudeMessage := range claudeRequest.Messages {
|
||||
openAIMessage := dto.Message{
|
||||
Role: claudeMessage.Role,
|
||||
}
|
||||
|
||||
//log.Printf("claudeMessage.Content: %v", claudeMessage.Content)
|
||||
if claudeMessage.IsStringContent() {
|
||||
openAIMessage.SetStringContent(claudeMessage.GetStringContent())
|
||||
} else {
|
||||
content, err := claudeMessage.ParseContent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contents := content
|
||||
var toolCalls []dto.ToolCallRequest
|
||||
mediaMessages := make([]dto.MediaContent, 0, len(contents))
|
||||
|
||||
for _, mediaMsg := range contents {
|
||||
switch mediaMsg.Type {
|
||||
case "text", "input_text":
|
||||
message := dto.MediaContent{
|
||||
Type: "text",
|
||||
Text: mediaMsg.GetText(),
|
||||
CacheControl: mediaMsg.CacheControl,
|
||||
}
|
||||
mediaMessages = append(mediaMessages, message)
|
||||
case "image":
|
||||
// Handle image conversion (base64 to URL or keep as is)
|
||||
imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data)
|
||||
//textContent += fmt.Sprintf("[Image: %s]", imageData)
|
||||
mediaMessage := dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{Url: imageData},
|
||||
}
|
||||
mediaMessages = append(mediaMessages, mediaMessage)
|
||||
case "tool_use":
|
||||
toolCall := dto.ToolCallRequest{
|
||||
ID: mediaMsg.Id,
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: mediaMsg.Name,
|
||||
Arguments: toJSONString(mediaMsg.Input),
|
||||
},
|
||||
}
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
case "tool_result":
|
||||
// Add tool result as a separate message
|
||||
toolName := mediaMsg.Name
|
||||
if toolName == "" {
|
||||
toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
|
||||
}
|
||||
oaiToolMessage := dto.Message{
|
||||
Role: "tool",
|
||||
Name: &toolName,
|
||||
ToolCallId: mediaMsg.ToolUseId,
|
||||
}
|
||||
//oaiToolMessage.SetStringContent(*mediaMsg.GetMediaContent().Text)
|
||||
if mediaMsg.IsStringContent() {
|
||||
oaiToolMessage.SetStringContent(mediaMsg.GetStringContent())
|
||||
} else {
|
||||
mediaContents := mediaMsg.ParseMediaContent()
|
||||
encodeJson, _ := common.Marshal(mediaContents)
|
||||
oaiToolMessage.SetStringContent(string(encodeJson))
|
||||
}
|
||||
openAIMessages = append(openAIMessages, oaiToolMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolCalls) > 0 {
|
||||
openAIMessage.SetToolCalls(toolCalls)
|
||||
}
|
||||
|
||||
if len(mediaMessages) > 0 && len(toolCalls) == 0 {
|
||||
openAIMessage.SetMediaContent(mediaMessages)
|
||||
}
|
||||
}
|
||||
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 {
|
||||
openAIMessages = append(openAIMessages, openAIMessage)
|
||||
}
|
||||
}
|
||||
|
||||
openAIRequest.Messages = openAIMessages
|
||||
|
||||
return &openAIRequest, nil
|
||||
}
|
||||
|
||||
func generateStopBlock(index int) *dto.ClaudeResponse {
|
||||
return &dto.ClaudeResponse{
|
||||
Type: "content_block_stop",
|
||||
Index: common.GetPointer[int](index),
|
||||
}
|
||||
}
|
||||
|
||||
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
||||
if oaiUsage == nil {
|
||||
return nil
|
||||
}
|
||||
usage := &dto.ClaudeUsage{
|
||||
InputTokens: oaiUsage.PromptTokens,
|
||||
OutputTokens: oaiUsage.CompletionTokens,
|
||||
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
||||
}
|
||||
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
|
||||
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
|
||||
}
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
|
||||
if info.ClaudeConvertInfo.Done {
|
||||
return nil
|
||||
}
|
||||
|
||||
var claudeResponses []*dto.ClaudeResponse
|
||||
// stopOpenBlocks emits the required content_block_stop event(s) for the currently open block(s)
|
||||
// according to Anthropic's SSE streaming state machine:
|
||||
// content_block_start -> content_block_delta* -> content_block_stop (per index).
|
||||
//
|
||||
// For text/thinking, there is at most one open block at info.ClaudeConvertInfo.Index.
|
||||
// For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0),
|
||||
// so we may have multiple open blocks and must stop each one explicitly.
|
||||
stopOpenBlocks := func() {
|
||||
switch info.ClaudeConvertInfo.LastMessagesType {
|
||||
case relaycommon.LastMessageTypeText, relaycommon.LastMessageTypeThinking:
|
||||
claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
|
||||
case relaycommon.LastMessageTypeTools:
|
||||
base := info.ClaudeConvertInfo.ToolCallBaseIndex
|
||||
for offset := 0; offset <= info.ClaudeConvertInfo.ToolCallMaxIndexOffset; offset++ {
|
||||
claudeResponses = append(claudeResponses, generateStopBlock(base+offset))
|
||||
}
|
||||
}
|
||||
}
|
||||
// stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index
|
||||
// to the next available slot for subsequent content_block_start events.
|
||||
//
|
||||
// This prevents invalid streams where a content_block_delta (e.g. thinking_delta) is emitted for an
|
||||
// index whose active content_block type is different (the typical cause of "Mismatched content block type").
|
||||
stopOpenBlocksAndAdvance := func() {
|
||||
if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeNone {
|
||||
return
|
||||
}
|
||||
stopOpenBlocks()
|
||||
switch info.ClaudeConvertInfo.LastMessagesType {
|
||||
case relaycommon.LastMessageTypeTools:
|
||||
info.ClaudeConvertInfo.Index = info.ClaudeConvertInfo.ToolCallBaseIndex + info.ClaudeConvertInfo.ToolCallMaxIndexOffset + 1
|
||||
info.ClaudeConvertInfo.ToolCallBaseIndex = 0
|
||||
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
|
||||
default:
|
||||
info.ClaudeConvertInfo.Index++
|
||||
}
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeNone
|
||||
}
|
||||
if info.SendResponseCount == 1 {
|
||||
msg := &dto.ClaudeMediaMessage{
|
||||
Id: openAIResponse.Id,
|
||||
Model: openAIResponse.Model,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Usage: &dto.ClaudeUsage{
|
||||
InputTokens: info.GetEstimatePromptTokens(),
|
||||
OutputTokens: 0,
|
||||
},
|
||||
}
|
||||
msg.SetContent(make([]any, 0))
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_start",
|
||||
Message: msg,
|
||||
})
|
||||
//claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
// Type: "ping",
|
||||
//})
|
||||
if openAIResponse.IsToolCall() {
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools
|
||||
info.ClaudeConvertInfo.ToolCallBaseIndex = 0
|
||||
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
|
||||
var toolCall dto.ToolCallResponse
|
||||
if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 {
|
||||
toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0]
|
||||
} else {
|
||||
first := openAIResponse.GetFirstToolCall()
|
||||
if first != nil {
|
||||
toolCall = *first
|
||||
} else {
|
||||
toolCall = dto.ToolCallResponse{}
|
||||
}
|
||||
}
|
||||
resp := &dto.ClaudeResponse{
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Id: toolCall.ID,
|
||||
Type: "tool_use",
|
||||
Name: toolCall.Function.Name,
|
||||
Input: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
resp.SetIndex(0)
|
||||
claudeResponses = append(claudeResponses, resp)
|
||||
// 首块包含工具 delta,则追加 input_json_delta
|
||||
if toolCall.Function.Arguments != "" {
|
||||
idx := 0
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "input_json_delta",
|
||||
PartialJson: &toolCall.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
// 判断首个响应是否存在内容(非标准的 OpenAI 响应)
|
||||
if len(openAIResponse.Choices) > 0 {
|
||||
reasoning := openAIResponse.Choices[0].Delta.GetReasoningContent()
|
||||
content := openAIResponse.Choices[0].Delta.GetContentString()
|
||||
|
||||
if reasoning != "" {
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking {
|
||||
stopOpenBlocksAndAdvance()
|
||||
}
|
||||
idx := info.ClaudeConvertInfo.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "thinking",
|
||||
Thinking: common.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
idx2 := idx
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx2,
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "thinking_delta",
|
||||
Thinking: &reasoning,
|
||||
},
|
||||
})
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking
|
||||
} else if content != "" {
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText {
|
||||
stopOpenBlocksAndAdvance()
|
||||
}
|
||||
idx := info.ClaudeConvertInfo.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
idx2 := idx
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx2,
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "text_delta",
|
||||
Text: common.GetPointer[string](content),
|
||||
},
|
||||
})
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText
|
||||
}
|
||||
}
|
||||
|
||||
// 如果首块就带 finish_reason,需要立即发送停止块
|
||||
if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" {
|
||||
info.FinishReason = *openAIResponse.Choices[0].FinishReason
|
||||
stopOpenBlocks()
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
}
|
||||
if oaiUsage != nil {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||
},
|
||||
})
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_stop",
|
||||
})
|
||||
info.ClaudeConvertInfo.Done = true
|
||||
}
|
||||
return claudeResponses
|
||||
}
|
||||
|
||||
if len(openAIResponse.Choices) == 0 {
|
||||
// no choices
|
||||
// 可能为非标准的 OpenAI 响应,判断是否已经完成
|
||||
if info.ClaudeConvertInfo.Done {
|
||||
stopOpenBlocks()
|
||||
oaiUsage := info.ClaudeConvertInfo.Usage
|
||||
if oaiUsage != nil {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||
},
|
||||
})
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_stop",
|
||||
})
|
||||
}
|
||||
return claudeResponses
|
||||
} else {
|
||||
chosenChoice := openAIResponse.Choices[0]
|
||||
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
|
||||
if doneChunk {
|
||||
info.FinishReason = *chosenChoice.FinishReason
|
||||
}
|
||||
|
||||
var claudeResponse dto.ClaudeResponse
|
||||
var isEmpty bool
|
||||
claudeResponse.Type = "content_block_delta"
|
||||
if len(chosenChoice.Delta.ToolCalls) > 0 {
|
||||
toolCalls := chosenChoice.Delta.ToolCalls
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeTools {
|
||||
stopOpenBlocksAndAdvance()
|
||||
info.ClaudeConvertInfo.ToolCallBaseIndex = info.ClaudeConvertInfo.Index
|
||||
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
|
||||
}
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools
|
||||
base := info.ClaudeConvertInfo.ToolCallBaseIndex
|
||||
maxOffset := info.ClaudeConvertInfo.ToolCallMaxIndexOffset
|
||||
|
||||
for i, toolCall := range toolCalls {
|
||||
offset := 0
|
||||
if toolCall.Index != nil {
|
||||
offset = *toolCall.Index
|
||||
} else {
|
||||
offset = i
|
||||
}
|
||||
if offset > maxOffset {
|
||||
maxOffset = offset
|
||||
}
|
||||
blockIndex := base + offset
|
||||
|
||||
idx := blockIndex
|
||||
if toolCall.Function.Name != "" {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Id: toolCall.ID,
|
||||
Type: "tool_use",
|
||||
Name: toolCall.Function.Name,
|
||||
Input: map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(toolCall.Function.Arguments) > 0 {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "input_json_delta",
|
||||
PartialJson: &toolCall.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = maxOffset
|
||||
info.ClaudeConvertInfo.Index = base + maxOffset
|
||||
} else {
|
||||
reasoning := chosenChoice.Delta.GetReasoningContent()
|
||||
textContent := chosenChoice.Delta.GetContentString()
|
||||
if reasoning != "" || textContent != "" {
|
||||
if reasoning != "" {
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking {
|
||||
stopOpenBlocksAndAdvance()
|
||||
idx := info.ClaudeConvertInfo.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "thinking",
|
||||
Thinking: common.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
}
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking
|
||||
claudeResponse.Delta = &dto.ClaudeMediaMessage{
|
||||
Type: "thinking_delta",
|
||||
Thinking: &reasoning,
|
||||
}
|
||||
} else {
|
||||
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText {
|
||||
stopOpenBlocksAndAdvance()
|
||||
idx := info.ClaudeConvertInfo.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
}
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText
|
||||
claudeResponse.Delta = &dto.ClaudeMediaMessage{
|
||||
Type: "text_delta",
|
||||
Text: common.GetPointer[string](textContent),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isEmpty = true
|
||||
}
|
||||
}
|
||||
|
||||
claudeResponse.Index = common.GetPointer[int](info.ClaudeConvertInfo.Index)
|
||||
if !isEmpty && claudeResponse.Delta != nil {
|
||||
claudeResponses = append(claudeResponses, &claudeResponse)
|
||||
}
|
||||
|
||||
if doneChunk || info.ClaudeConvertInfo.Done {
|
||||
stopOpenBlocks()
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
}
|
||||
if oaiUsage != nil {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||
},
|
||||
})
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_stop",
|
||||
})
|
||||
info.ClaudeConvertInfo.Done = true
|
||||
return claudeResponses
|
||||
}
|
||||
}
|
||||
|
||||
return claudeResponses
|
||||
}
|
||||
|
||||
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.ClaudeResponse {
|
||||
var stopReason string
|
||||
contents := make([]dto.ClaudeMediaMessage, 0)
|
||||
claudeResponse := &dto.ClaudeResponse{
|
||||
Id: openAIResponse.Id,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Model: openAIResponse.Model,
|
||||
}
|
||||
for _, choice := range openAIResponse.Choices {
|
||||
stopReason = stopReasonOpenAI2Claude(choice.FinishReason)
|
||||
if choice.FinishReason == "tool_calls" {
|
||||
for _, toolUse := range choice.Message.ParseToolCalls() {
|
||||
claudeContent := dto.ClaudeMediaMessage{}
|
||||
claudeContent.Type = "tool_use"
|
||||
claudeContent.Id = toolUse.ID
|
||||
claudeContent.Name = toolUse.Function.Name
|
||||
var mapParams map[string]interface{}
|
||||
if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil {
|
||||
claudeContent.Input = mapParams
|
||||
} else {
|
||||
claudeContent.Input = toolUse.Function.Arguments
|
||||
}
|
||||
contents = append(contents, claudeContent)
|
||||
}
|
||||
} else {
|
||||
claudeContent := dto.ClaudeMediaMessage{}
|
||||
claudeContent.Type = "text"
|
||||
claudeContent.SetText(choice.Message.StringContent())
|
||||
contents = append(contents, claudeContent)
|
||||
}
|
||||
}
|
||||
claudeResponse.Content = contents
|
||||
claudeResponse.StopReason = stopReason
|
||||
claudeResponse.Usage = &dto.ClaudeUsage{
|
||||
InputTokens: openAIResponse.PromptTokens,
|
||||
OutputTokens: openAIResponse.CompletionTokens,
|
||||
}
|
||||
|
||||
return claudeResponse
|
||||
}
|
||||
|
||||
func stopReasonOpenAI2Claude(reason string) string {
|
||||
return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason)
|
||||
}
|
||||
|
||||
func toJSONString(v interface{}) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||
openaiRequest := &dto.GeneralOpenAIRequest{
|
||||
Model: info.UpstreamModelName,
|
||||
Stream: lo.ToPtr(info.IsStream),
|
||||
}
|
||||
|
||||
// 转换 messages
|
||||
var messages []dto.Message
|
||||
for _, content := range geminiRequest.Contents {
|
||||
message := dto.Message{
|
||||
Role: convertGeminiRoleToOpenAI(content.Role),
|
||||
}
|
||||
|
||||
// 处理 parts
|
||||
var mediaContents []dto.MediaContent
|
||||
var toolCalls []dto.ToolCallRequest
|
||||
for _, part := range content.Parts {
|
||||
if part.Text != "" {
|
||||
mediaContent := dto.MediaContent{
|
||||
Type: "text",
|
||||
Text: part.Text,
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.InlineData != nil {
|
||||
mediaContent := dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{
|
||||
Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data),
|
||||
Detail: "auto",
|
||||
MimeType: part.InlineData.MimeType,
|
||||
},
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.FileData != nil {
|
||||
mediaContent := dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{
|
||||
Url: part.FileData.FileUri,
|
||||
Detail: "auto",
|
||||
MimeType: part.FileData.MimeType,
|
||||
},
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.FunctionCall != nil {
|
||||
// 处理 Gemini 的工具调用
|
||||
toolCall := dto.ToolCallRequest{
|
||||
ID: fmt.Sprintf("call_%d", len(toolCalls)+1), // 生成唯一ID
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: part.FunctionCall.FunctionName,
|
||||
Arguments: toJSONString(part.FunctionCall.Arguments),
|
||||
},
|
||||
}
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
} else if part.FunctionResponse != nil {
|
||||
// 处理 Gemini 的工具响应,创建单独的 tool 消息
|
||||
toolMessage := dto.Message{
|
||||
Role: "tool",
|
||||
ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)), // 使用对应的调用ID
|
||||
}
|
||||
toolMessage.SetStringContent(toJSONString(part.FunctionResponse.Response))
|
||||
messages = append(messages, toolMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置消息内容
|
||||
if len(toolCalls) > 0 {
|
||||
// 如果有工具调用,设置工具调用
|
||||
message.SetToolCalls(toolCalls)
|
||||
} else if len(mediaContents) == 1 && mediaContents[0].Type == "text" {
|
||||
// 如果只有一个文本内容,直接设置字符串
|
||||
message.Content = mediaContents[0].Text
|
||||
} else if len(mediaContents) > 0 {
|
||||
// 如果有多个内容或包含媒体,设置为数组
|
||||
message.SetMediaContent(mediaContents)
|
||||
}
|
||||
|
||||
// 只有当消息有内容或工具调用时才添加
|
||||
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 {
|
||||
messages = append(messages, message)
|
||||
}
|
||||
}
|
||||
|
||||
openaiRequest.Messages = messages
|
||||
|
||||
if geminiRequest.GenerationConfig.Temperature != nil {
|
||||
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
|
||||
}
|
||||
if geminiRequest.GenerationConfig.TopP != nil && *geminiRequest.GenerationConfig.TopP > 0 {
|
||||
openaiRequest.TopP = lo.ToPtr(*geminiRequest.GenerationConfig.TopP)
|
||||
}
|
||||
if geminiRequest.GenerationConfig.TopK != nil && *geminiRequest.GenerationConfig.TopK > 0 {
|
||||
openaiRequest.TopK = lo.ToPtr(int(*geminiRequest.GenerationConfig.TopK))
|
||||
}
|
||||
if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
|
||||
openaiRequest.MaxTokens = lo.ToPtr(*geminiRequest.GenerationConfig.MaxOutputTokens)
|
||||
}
|
||||
// gemini stop sequences 最多 5 个,openai stop 最多 4 个
|
||||
if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
|
||||
openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:4]
|
||||
}
|
||||
if geminiRequest.GenerationConfig.CandidateCount != nil && *geminiRequest.GenerationConfig.CandidateCount > 0 {
|
||||
openaiRequest.N = lo.ToPtr(*geminiRequest.GenerationConfig.CandidateCount)
|
||||
}
|
||||
|
||||
// 转换工具调用
|
||||
if len(geminiRequest.GetTools()) > 0 {
|
||||
var tools []dto.ToolCallRequest
|
||||
for _, tool := range geminiRequest.GetTools() {
|
||||
if tool.FunctionDeclarations != nil {
|
||||
functionDeclarations, err := common.Any2Type[[]dto.FunctionRequest](tool.FunctionDeclarations)
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to parse gemini function declarations: %v (type=%T)", err, tool.FunctionDeclarations))
|
||||
continue
|
||||
}
|
||||
for _, function := range functionDeclarations {
|
||||
openAITool := dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: function.Name,
|
||||
Description: function.Description,
|
||||
Parameters: function.Parameters,
|
||||
},
|
||||
}
|
||||
tools = append(tools, openAITool)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
openaiRequest.Tools = tools
|
||||
}
|
||||
}
|
||||
|
||||
// gemini system instructions
|
||||
if geminiRequest.SystemInstructions != nil {
|
||||
// 将系统指令作为第一条消息插入
|
||||
systemMessage := dto.Message{
|
||||
Role: "system",
|
||||
Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts),
|
||||
}
|
||||
openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...)
|
||||
}
|
||||
|
||||
return openaiRequest, nil
|
||||
}
|
||||
|
||||
func convertGeminiRoleToOpenAI(geminiRole string) string {
|
||||
switch geminiRole {
|
||||
case "user":
|
||||
return "user"
|
||||
case "model":
|
||||
return "assistant"
|
||||
case "function":
|
||||
return "function"
|
||||
default:
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
|
||||
func extractTextFromGeminiParts(parts []dto.GeminiPart) string {
|
||||
var texts []string
|
||||
for _, part := range parts {
|
||||
if part.Text != "" {
|
||||
texts = append(texts, part.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
|
||||
// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
|
||||
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
|
||||
geminiResponse := &dto.GeminiChatResponse{
|
||||
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: openAIResponse.PromptTokens,
|
||||
CandidatesTokenCount: openAIResponse.CompletionTokens,
|
||||
TotalTokenCount: openAIResponse.PromptTokens + openAIResponse.CompletionTokens,
|
||||
},
|
||||
}
|
||||
|
||||
for _, choice := range openAIResponse.Choices {
|
||||
candidate := dto.GeminiChatCandidate{
|
||||
Index: int64(choice.Index),
|
||||
SafetyRatings: []dto.GeminiChatSafetyRating{},
|
||||
}
|
||||
|
||||
// 设置结束原因
|
||||
var finishReason string
|
||||
switch choice.FinishReason {
|
||||
case "stop":
|
||||
finishReason = "STOP"
|
||||
case "length":
|
||||
finishReason = "MAX_TOKENS"
|
||||
case "content_filter":
|
||||
finishReason = "SAFETY"
|
||||
case "tool_calls":
|
||||
finishReason = "STOP"
|
||||
default:
|
||||
finishReason = "STOP"
|
||||
}
|
||||
candidate.FinishReason = &finishReason
|
||||
|
||||
// 转换消息内容
|
||||
content := dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: make([]dto.GeminiPart, 0),
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
toolCalls := choice.Message.ParseToolCalls()
|
||||
if len(toolCalls) > 0 {
|
||||
for _, toolCall := range toolCalls {
|
||||
// 解析参数
|
||||
var args map[string]interface{}
|
||||
if toolCall.Function.Arguments != "" {
|
||||
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
|
||||
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
|
||||
}
|
||||
} else {
|
||||
args = make(map[string]interface{})
|
||||
}
|
||||
|
||||
part := dto.GeminiPart{
|
||||
FunctionCall: &dto.FunctionCall{
|
||||
FunctionName: toolCall.Function.Name,
|
||||
Arguments: args,
|
||||
},
|
||||
}
|
||||
content.Parts = append(content.Parts, part)
|
||||
}
|
||||
} else {
|
||||
// 处理文本内容
|
||||
textContent := choice.Message.StringContent()
|
||||
if textContent != "" {
|
||||
part := dto.GeminiPart{
|
||||
Text: textContent,
|
||||
}
|
||||
content.Parts = append(content.Parts, part)
|
||||
}
|
||||
}
|
||||
|
||||
candidate.Content = content
|
||||
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
|
||||
}
|
||||
|
||||
return geminiResponse
|
||||
}
|
||||
|
||||
// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式
|
||||
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
|
||||
// 检查是否有实际内容或结束标志
|
||||
hasContent := false
|
||||
hasFinishReason := false
|
||||
for _, choice := range openAIResponse.Choices {
|
||||
if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) {
|
||||
hasContent = true
|
||||
}
|
||||
if choice.FinishReason != nil {
|
||||
hasFinishReason = true
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据
|
||||
if !hasContent && !hasFinishReason {
|
||||
return nil
|
||||
}
|
||||
|
||||
geminiResponse := &dto.GeminiChatResponse{
|
||||
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: info.GetEstimatePromptTokens(),
|
||||
CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息
|
||||
TotalTokenCount: info.GetEstimatePromptTokens(),
|
||||
},
|
||||
}
|
||||
|
||||
if openAIResponse.Usage != nil {
|
||||
geminiResponse.UsageMetadata.PromptTokenCount = openAIResponse.Usage.PromptTokens
|
||||
geminiResponse.UsageMetadata.CandidatesTokenCount = openAIResponse.Usage.CompletionTokens
|
||||
geminiResponse.UsageMetadata.TotalTokenCount = openAIResponse.Usage.TotalTokens
|
||||
}
|
||||
|
||||
for _, choice := range openAIResponse.Choices {
|
||||
candidate := dto.GeminiChatCandidate{
|
||||
Index: int64(choice.Index),
|
||||
SafetyRatings: []dto.GeminiChatSafetyRating{},
|
||||
}
|
||||
|
||||
// 设置结束原因
|
||||
if choice.FinishReason != nil {
|
||||
var finishReason string
|
||||
switch *choice.FinishReason {
|
||||
case "stop":
|
||||
finishReason = "STOP"
|
||||
case "length":
|
||||
finishReason = "MAX_TOKENS"
|
||||
case "content_filter":
|
||||
finishReason = "SAFETY"
|
||||
case "tool_calls":
|
||||
finishReason = "STOP"
|
||||
default:
|
||||
finishReason = "STOP"
|
||||
}
|
||||
candidate.FinishReason = &finishReason
|
||||
}
|
||||
|
||||
// 转换消息内容
|
||||
content := dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: make([]dto.GeminiPart, 0),
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if choice.Delta.ToolCalls != nil {
|
||||
for _, toolCall := range choice.Delta.ToolCalls {
|
||||
// 解析参数
|
||||
var args map[string]interface{}
|
||||
if toolCall.Function.Arguments != "" {
|
||||
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
|
||||
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
|
||||
}
|
||||
} else {
|
||||
args = make(map[string]interface{})
|
||||
}
|
||||
|
||||
part := dto.GeminiPart{
|
||||
FunctionCall: &dto.FunctionCall{
|
||||
FunctionName: toolCall.Function.Name,
|
||||
Arguments: args,
|
||||
},
|
||||
}
|
||||
content.Parts = append(content.Parts, part)
|
||||
}
|
||||
} else {
|
||||
// 处理文本内容
|
||||
textContent := choice.Delta.GetContentString()
|
||||
if textContent != "" {
|
||||
part := dto.GeminiPart{
|
||||
Text: textContent,
|
||||
}
|
||||
content.Parts = append(content.Parts, part)
|
||||
}
|
||||
}
|
||||
|
||||
candidate.Content = content
|
||||
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
|
||||
}
|
||||
|
||||
return geminiResponse
|
||||
}
|
||||
70
service/download.go
Normal file
70
service/download.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
)
|
||||
|
||||
// WorkerRequest Worker请求的数据结构
|
||||
type WorkerRequest struct {
|
||||
URL string `json:"url"`
|
||||
Key string `json:"key"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// DoWorkerRequest 通过Worker发送请求
|
||||
func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
|
||||
if !system_setting.EnableWorker() {
|
||||
return nil, fmt.Errorf("worker not enabled")
|
||||
}
|
||||
if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
|
||||
return nil, fmt.Errorf("only support https url")
|
||||
}
|
||||
|
||||
// SSRF防护:验证请求URL
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||
return nil, fmt.Errorf("request reject: %v", err)
|
||||
}
|
||||
|
||||
workerUrl := system_setting.WorkerUrl
|
||||
if !strings.HasSuffix(workerUrl, "/") {
|
||||
workerUrl += "/"
|
||||
}
|
||||
|
||||
// 序列化worker请求数据
|
||||
workerPayload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal worker payload: %v", err)
|
||||
}
|
||||
|
||||
return GetHttpClient().Post(workerUrl, "application/json", bytes.NewBuffer(workerPayload))
|
||||
}
|
||||
|
||||
func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) {
|
||||
if system_setting.EnableWorker() {
|
||||
common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
|
||||
req := &WorkerRequest{
|
||||
URL: originUrl,
|
||||
Key: system_setting.WorkerValidKey,
|
||||
}
|
||||
return DoWorkerRequest(req)
|
||||
} else {
|
||||
// SSRF防护:验证请求URL(非Worker模式)
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||
return nil, fmt.Errorf("request reject: %v", err)
|
||||
}
|
||||
|
||||
common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", ")))
|
||||
return GetHttpClient().Get(originUrl)
|
||||
}
|
||||
}
|
||||
13
service/epay.go
Normal file
13
service/epay.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
)
|
||||
|
||||
func GetCallbackAddress() string {
|
||||
if operation_setting.CustomCallbackAddress == "" {
|
||||
return system_setting.ServerAddress
|
||||
}
|
||||
return operation_setting.CustomCallbackAddress
|
||||
}
|
||||
221
service/error.go
Normal file
221
service/error.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
func MidjourneyErrorWrapper(code int, desc string) *dto.MidjourneyResponse {
|
||||
return &dto.MidjourneyResponse{
|
||||
Code: code,
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
func MidjourneyErrorWithStatusCodeWrapper(code int, desc string, statusCode int) *dto.MidjourneyResponseWithStatusCode {
|
||||
return &dto.MidjourneyResponseWithStatusCode{
|
||||
StatusCode: statusCode,
|
||||
Response: *MidjourneyErrorWrapper(code, desc),
|
||||
}
|
||||
}
|
||||
|
||||
//// OpenAIErrorWrapper wraps an error into an OpenAIErrorWithStatusCode
|
||||
//func OpenAIErrorWrapper(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode {
|
||||
// text := err.Error()
|
||||
// lowerText := strings.ToLower(text)
|
||||
// if !strings.HasPrefix(lowerText, "get file base64 from url") && !strings.HasPrefix(lowerText, "mime type is not supported") {
|
||||
// if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
|
||||
// common.SysLog(fmt.Sprintf("error: %s", text))
|
||||
// text = "请求上游地址失败"
|
||||
// }
|
||||
// }
|
||||
// openAIError := dto.OpenAIError{
|
||||
// Message: text,
|
||||
// Type: "new_api_error",
|
||||
// Code: code,
|
||||
// }
|
||||
// return &dto.OpenAIErrorWithStatusCode{
|
||||
// Error: openAIError,
|
||||
// StatusCode: statusCode,
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//func OpenAIErrorWrapperLocal(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode {
|
||||
// openaiErr := OpenAIErrorWrapper(err, code, statusCode)
|
||||
// openaiErr.LocalError = true
|
||||
// return openaiErr
|
||||
//}
|
||||
|
||||
func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode {
|
||||
text := err.Error()
|
||||
lowerText := strings.ToLower(text)
|
||||
if !strings.HasPrefix(lowerText, "get file base64 from url") {
|
||||
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
|
||||
common.SysLog(fmt.Sprintf("error: %s", text))
|
||||
text = "请求上游地址失败"
|
||||
}
|
||||
}
|
||||
claudeError := types.ClaudeError{
|
||||
Message: text,
|
||||
Type: "new_api_error",
|
||||
}
|
||||
return &dto.ClaudeErrorWithStatusCode{
|
||||
Error: claudeError,
|
||||
StatusCode: statusCode,
|
||||
}
|
||||
}
|
||||
|
||||
func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode {
|
||||
claudeErr := ClaudeErrorWrapper(err, code, statusCode)
|
||||
claudeErr.LocalError = true
|
||||
return claudeErr
|
||||
}
|
||||
|
||||
func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) {
|
||||
newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
CloseResponseBodyGracefully(resp)
|
||||
var errResponse dto.GeneralErrorResponse
|
||||
buildErrWithBody := func(message string) error {
|
||||
if message == "" {
|
||||
return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, string(responseBody))
|
||||
}
|
||||
|
||||
err = common.Unmarshal(responseBody, &errResponse)
|
||||
if err != nil {
|
||||
if showBodyWhenFail {
|
||||
newApiErr.Err = buildErrWithBody("")
|
||||
} else {
|
||||
logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
|
||||
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if common.GetJsonType(errResponse.Error) == "object" {
|
||||
// General format error (OpenAI, Anthropic, Gemini, etc.)
|
||||
oaiError := errResponse.TryToOpenAIError()
|
||||
if oaiError != nil {
|
||||
newApiErr = types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
if showBodyWhenFail {
|
||||
newApiErr.Err = buildErrWithBody(newApiErr.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode)
|
||||
if showBodyWhenFail {
|
||||
newApiErr.Err = buildErrWithBody(newApiErr.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ResetStatusCode(newApiErr *types.NewAPIError, statusCodeMappingStr string) {
|
||||
if newApiErr == nil {
|
||||
return
|
||||
}
|
||||
if statusCodeMappingStr == "" || statusCodeMappingStr == "{}" {
|
||||
return
|
||||
}
|
||||
statusCodeMapping := make(map[string]any)
|
||||
err := common.Unmarshal([]byte(statusCodeMappingStr), &statusCodeMapping)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if newApiErr.StatusCode == http.StatusOK {
|
||||
return
|
||||
}
|
||||
codeStr := strconv.Itoa(newApiErr.StatusCode)
|
||||
if value, ok := statusCodeMapping[codeStr]; ok {
|
||||
intCode, ok := parseStatusCodeMappingValue(value)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
newApiErr.StatusCode = intCode
|
||||
}
|
||||
}
|
||||
|
||||
func parseStatusCodeMappingValue(value any) (int, bool) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return 0, false
|
||||
}
|
||||
statusCode, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return statusCode, true
|
||||
case float64:
|
||||
if v != math.Trunc(v) {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case json.Number:
|
||||
statusCode, err := strconv.Atoi(v.String())
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return statusCode, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func TaskErrorWrapperLocal(err error, code string, statusCode int) *dto.TaskError {
|
||||
openaiErr := TaskErrorWrapper(err, code, statusCode)
|
||||
openaiErr.LocalError = true
|
||||
return openaiErr
|
||||
}
|
||||
|
||||
func TaskErrorWrapper(err error, code string, statusCode int) *dto.TaskError {
|
||||
text := err.Error()
|
||||
lowerText := strings.ToLower(text)
|
||||
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
|
||||
common.SysLog(fmt.Sprintf("error: %s", text))
|
||||
//text = "请求上游地址失败"
|
||||
text = common.MaskSensitiveInfo(text)
|
||||
}
|
||||
//避免暴露内部错误
|
||||
taskError := &dto.TaskError{
|
||||
Code: code,
|
||||
Message: text,
|
||||
StatusCode: statusCode,
|
||||
Error: err,
|
||||
}
|
||||
|
||||
return taskError
|
||||
}
|
||||
|
||||
// TaskErrorFromAPIError 将 PreConsumeBilling 返回的 NewAPIError 转换为 TaskError。
|
||||
func TaskErrorFromAPIError(apiErr *types.NewAPIError) *dto.TaskError {
|
||||
if apiErr == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.TaskError{
|
||||
Code: string(apiErr.GetErrorCode()),
|
||||
Message: apiErr.Err.Error(),
|
||||
StatusCode: apiErr.StatusCode,
|
||||
Error: apiErr.Err,
|
||||
}
|
||||
}
|
||||
57
service/error_test.go
Normal file
57
service/error_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResetStatusCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
statusCodeConfig string
|
||||
expectedCode int
|
||||
}{
|
||||
{
|
||||
name: "map string value",
|
||||
statusCode: 429,
|
||||
statusCodeConfig: `{"429":"503"}`,
|
||||
expectedCode: 503,
|
||||
},
|
||||
{
|
||||
name: "map int value",
|
||||
statusCode: 429,
|
||||
statusCodeConfig: `{"429":503}`,
|
||||
expectedCode: 503,
|
||||
},
|
||||
{
|
||||
name: "skip invalid string value",
|
||||
statusCode: 429,
|
||||
statusCodeConfig: `{"429":"bad-code"}`,
|
||||
expectedCode: 429,
|
||||
},
|
||||
{
|
||||
name: "skip status code 200",
|
||||
statusCode: 200,
|
||||
statusCodeConfig: `{"200":503}`,
|
||||
expectedCode: 200,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newAPIError := &types.NewAPIError{
|
||||
StatusCode: tc.statusCode,
|
||||
}
|
||||
ResetStatusCode(newAPIError, tc.statusCodeConfig)
|
||||
require.Equal(t, tc.expectedCode, newAPIError.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
203
service/file_decoder.go
Normal file
203
service/file_decoder.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetFileTypeFromUrl 获取文件类型,返回 mime type, 例如 image/jpeg, image/png, image/gif, image/bmp, image/tiff, application/pdf
|
||||
// 如果获取失败,返回 application/octet-stream
|
||||
func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, error) {
|
||||
response, err := DoDownloadRequest(url, []string{"get_mime_type", strings.Join(reason, ", ")}...)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("fail to get file type from url: %s, error: %s", url, err.Error()))
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
logger.LogError(c, fmt.Sprintf("failed to download file from %s, status code: %d", url, response.StatusCode))
|
||||
return "", fmt.Errorf("failed to download file, status code: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
if headerType := strings.TrimSpace(response.Header.Get("Content-Type")); headerType != "" {
|
||||
if i := strings.Index(headerType, ";"); i != -1 {
|
||||
headerType = headerType[:i]
|
||||
}
|
||||
if headerType != "application/octet-stream" {
|
||||
return headerType, nil
|
||||
}
|
||||
}
|
||||
|
||||
if cd := response.Header.Get("Content-Disposition"); cd != "" {
|
||||
parts := strings.Split(cd, ";")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(strings.ToLower(part), "filename=") {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(part, "filename="))
|
||||
if len(name) > 2 && name[0] == '"' && name[len(name)-1] == '"' {
|
||||
name = name[1 : len(name)-1]
|
||||
}
|
||||
if dot := strings.LastIndex(name, "."); dot != -1 && dot+1 < len(name) {
|
||||
ext := strings.ToLower(name[dot+1:])
|
||||
if ext != "" {
|
||||
mt := GetMimeTypeByExtension(ext)
|
||||
if mt != "application/octet-stream" {
|
||||
return mt, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanedURL := url
|
||||
if q := strings.Index(cleanedURL, "?"); q != -1 {
|
||||
cleanedURL = cleanedURL[:q]
|
||||
}
|
||||
if slash := strings.LastIndex(cleanedURL, "/"); slash != -1 && slash+1 < len(cleanedURL) {
|
||||
last := cleanedURL[slash+1:]
|
||||
if dot := strings.LastIndex(last, "."); dot != -1 && dot+1 < len(last) {
|
||||
ext := strings.ToLower(last[dot+1:])
|
||||
if ext != "" {
|
||||
mt := GetMimeTypeByExtension(ext)
|
||||
if mt != "application/octet-stream" {
|
||||
return mt, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var readData []byte
|
||||
limits := []int{512, 8 * 1024, 24 * 1024, 64 * 1024}
|
||||
for _, limit := range limits {
|
||||
logger.LogDebug(c, fmt.Sprintf("Trying to read %d bytes to determine file type", limit))
|
||||
if len(readData) < limit {
|
||||
need := limit - len(readData)
|
||||
tmp := make([]byte, need)
|
||||
n, _ := io.ReadFull(response.Body, tmp)
|
||||
if n > 0 {
|
||||
readData = append(readData, tmp[:n]...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(readData) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sniffed := http.DetectContentType(readData)
|
||||
if sniffed != "" && sniffed != "application/octet-stream" {
|
||||
return sniffed, nil
|
||||
}
|
||||
|
||||
if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil {
|
||||
switch strings.ToLower(format) {
|
||||
case "jpeg", "jpg":
|
||||
return "image/jpeg", nil
|
||||
case "png":
|
||||
return "image/png", nil
|
||||
case "gif":
|
||||
return "image/gif", nil
|
||||
case "bmp":
|
||||
return "image/bmp", nil
|
||||
case "tiff":
|
||||
return "image/tiff", nil
|
||||
default:
|
||||
if format != "" {
|
||||
return "image/" + strings.ToLower(format), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
|
||||
// GetFileBase64FromUrl 从 URL 获取文件的 base64 编码数据
|
||||
// Deprecated: 请使用 GetBase64Data 配合 types.NewURLFileSource 替代
|
||||
// 此函数保留用于向后兼容,内部已重构为调用统一的文件服务
|
||||
func GetFileBase64FromUrl(c *gin.Context, url string, reason ...string) (*types.LocalFileData, error) {
|
||||
source := types.NewURLFileSource(url)
|
||||
cachedData, err := LoadFileSource(c, source, reason...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 转换为旧的 LocalFileData 格式以保持兼容
|
||||
base64Data, err := cachedData.GetBase64Data()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.LocalFileData{
|
||||
Base64Data: base64Data,
|
||||
MimeType: cachedData.MimeType,
|
||||
Size: cachedData.Size,
|
||||
Url: url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetMimeTypeByExtension(ext string) string {
|
||||
// Convert to lowercase for case-insensitive comparison
|
||||
ext = strings.ToLower(ext)
|
||||
switch ext {
|
||||
// Text files
|
||||
case "txt", "md", "markdown", "csv", "json", "xml", "html", "htm":
|
||||
return "text/plain"
|
||||
|
||||
// Image files
|
||||
case "jpg", "jpeg":
|
||||
return "image/jpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "jfif":
|
||||
return "image/jpeg"
|
||||
|
||||
// Audio files
|
||||
case "mp3":
|
||||
return "audio/mp3"
|
||||
case "wav":
|
||||
return "audio/wav"
|
||||
case "mpeg":
|
||||
return "audio/mpeg"
|
||||
|
||||
// Video files
|
||||
case "mp4":
|
||||
return "video/mp4"
|
||||
case "wmv":
|
||||
return "video/wmv"
|
||||
case "flv":
|
||||
return "video/flv"
|
||||
case "mov":
|
||||
return "video/mov"
|
||||
case "mpg":
|
||||
return "video/mpg"
|
||||
case "avi":
|
||||
return "video/avi"
|
||||
case "mpegps":
|
||||
return "video/mpegps"
|
||||
|
||||
// Document files
|
||||
case "pdf":
|
||||
return "application/pdf"
|
||||
|
||||
default:
|
||||
return "application/octet-stream" // Default for unknown types
|
||||
}
|
||||
}
|
||||
471
service/file_service.go
Normal file
471
service/file_service.go
Normal file
@@ -0,0 +1,471 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// FileService 统一的文件处理服务
|
||||
// 提供文件下载、解码、缓存等功能的统一入口
|
||||
|
||||
// getContextCacheKey 生成 context 缓存的 key
|
||||
func getContextCacheKey(url string) string {
|
||||
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
|
||||
}
|
||||
|
||||
// LoadFileSource 加载文件源数据
|
||||
// 这是统一的入口,会自动处理缓存和不同的来源类型
|
||||
func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
|
||||
if source == nil {
|
||||
return nil, fmt.Errorf("file source is nil")
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("LoadFileSource starting for: %s", source.GetIdentifier()))
|
||||
}
|
||||
|
||||
// 1. 快速检查内部缓存
|
||||
if source.HasCache() {
|
||||
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
return source.GetCache(), nil
|
||||
}
|
||||
|
||||
// 2. 加锁保护加载过程
|
||||
source.Mu().Lock()
|
||||
defer source.Mu().Unlock()
|
||||
|
||||
// 3. 双重检查
|
||||
if source.HasCache() {
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
return source.GetCache(), nil
|
||||
}
|
||||
|
||||
// 4. 如果是 URL,检查 Context 缓存
|
||||
var contextKey string
|
||||
if source.IsURL() && c != nil {
|
||||
contextKey = getContextCacheKey(source.URL)
|
||||
if cachedData, exists := c.Get(contextKey); exists {
|
||||
data := cachedData.(*types.CachedFileData)
|
||||
source.SetCache(data)
|
||||
registerSourceForCleanup(c, source)
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行加载逻辑
|
||||
var cachedData *types.CachedFileData
|
||||
var err error
|
||||
|
||||
if source.IsURL() {
|
||||
cachedData, err = loadFromURL(c, source.URL, reason...)
|
||||
} else {
|
||||
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. 设置缓存
|
||||
source.SetCache(cachedData)
|
||||
if contextKey != "" && c != nil {
|
||||
c.Set(contextKey, cachedData)
|
||||
}
|
||||
|
||||
// 7. 注册到 context 以便请求结束时自动清理
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
|
||||
return cachedData, nil
|
||||
}
|
||||
|
||||
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
|
||||
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
|
||||
if source.IsRegistered() {
|
||||
return
|
||||
}
|
||||
|
||||
key := string(constant.ContextKeyFileSourcesToCleanup)
|
||||
var sources []*types.FileSource
|
||||
if existing, exists := c.Get(key); exists {
|
||||
sources = existing.([]*types.FileSource)
|
||||
}
|
||||
sources = append(sources, source)
|
||||
c.Set(key, sources)
|
||||
source.SetRegistered(true)
|
||||
}
|
||||
|
||||
// CleanupFileSources 清理请求中所有注册的 FileSource
|
||||
// 应在请求结束时调用(通常由中间件自动调用)
|
||||
func CleanupFileSources(c *gin.Context) {
|
||||
key := string(constant.ContextKeyFileSourcesToCleanup)
|
||||
if sources, exists := c.Get(key); exists {
|
||||
for _, source := range sources.([]*types.FileSource) {
|
||||
if cache := source.GetCache(); cache != nil {
|
||||
cache.Close()
|
||||
}
|
||||
}
|
||||
c.Set(key, nil) // 清除引用
|
||||
}
|
||||
}
|
||||
|
||||
// loadFromURL 从 URL 加载文件
|
||||
func loadFromURL(c *gin.Context, url string, reason ...string) (*types.CachedFileData, error) {
|
||||
// 下载文件
|
||||
var maxFileSize = constant.MaxFileDownloadMB * 1024 * 1024
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, "loadFromURL: initiating download")
|
||||
}
|
||||
resp, err := DoDownloadRequest(url, reason...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download file from %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("failed to download file, status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 读取文件内容(限制大小)
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, "loadFromURL: reading response body")
|
||||
}
|
||||
fileBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxFileSize+1)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file content: %w", err)
|
||||
}
|
||||
if len(fileBytes) > maxFileSize {
|
||||
return nil, fmt.Errorf("file size exceeds maximum allowed size: %dMB", constant.MaxFileDownloadMB)
|
||||
}
|
||||
|
||||
// 转换为 base64
|
||||
base64Data := base64.StdEncoding.EncodeToString(fileBytes)
|
||||
|
||||
// 智能获取 MIME 类型
|
||||
mimeType := smartDetectMimeType(resp, url, fileBytes)
|
||||
|
||||
// 判断是否使用磁盘缓存
|
||||
base64Size := int64(len(base64Data))
|
||||
var cachedData *types.CachedFileData
|
||||
|
||||
if shouldUseDiskCache(base64Size) {
|
||||
// 使用磁盘缓存
|
||||
diskPath, err := writeToDiskCache(base64Data)
|
||||
if err != nil {
|
||||
// 磁盘缓存失败,回退到内存
|
||||
logger.LogWarn(c, fmt.Sprintf("Failed to write to disk cache, falling back to memory: %v", err))
|
||||
cachedData = types.NewMemoryCachedData(base64Data, mimeType, int64(len(fileBytes)))
|
||||
} else {
|
||||
cachedData = types.NewDiskCachedData(diskPath, mimeType, int64(len(fileBytes)))
|
||||
cachedData.DiskSize = base64Size
|
||||
cachedData.OnClose = func(size int64) {
|
||||
common.DecrementDiskFiles(size)
|
||||
}
|
||||
common.IncrementDiskFiles(base64Size)
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("File cached to disk: %s, size: %d bytes", diskPath, base64Size))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 使用内存缓存
|
||||
cachedData = types.NewMemoryCachedData(base64Data, mimeType, int64(len(fileBytes)))
|
||||
}
|
||||
|
||||
// 如果是图片,尝试获取图片配置
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, "loadFromURL: decoding image config")
|
||||
}
|
||||
config, format, err := decodeImageConfig(fileBytes)
|
||||
if err == nil {
|
||||
cachedData.ImageConfig = &config
|
||||
cachedData.ImageFormat = format
|
||||
// 如果通过图片解码获取了更准确的格式,更新 MIME 类型
|
||||
if mimeType == "application/octet-stream" || mimeType == "" {
|
||||
cachedData.MimeType = "image/" + format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cachedData, nil
|
||||
}
|
||||
|
||||
// shouldUseDiskCache 判断是否应该使用磁盘缓存
|
||||
func shouldUseDiskCache(dataSize int64) bool {
|
||||
return common.ShouldUseDiskCache(dataSize)
|
||||
}
|
||||
|
||||
// writeToDiskCache 将数据写入磁盘缓存
|
||||
func writeToDiskCache(base64Data string) (string, error) {
|
||||
return common.WriteDiskCacheFileString(common.DiskCacheTypeFile, base64Data)
|
||||
}
|
||||
|
||||
// smartDetectMimeType 智能检测 MIME 类型
|
||||
func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) string {
|
||||
// 1. 尝试从 Content-Type header 获取
|
||||
mimeType := resp.Header.Get("Content-Type")
|
||||
if idx := strings.Index(mimeType, ";"); idx != -1 {
|
||||
mimeType = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
if mimeType != "" && mimeType != "application/octet-stream" {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
// 2. 尝试从 Content-Disposition header 的 filename 获取
|
||||
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
|
||||
parts := strings.Split(cd, ";")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(strings.ToLower(part), "filename=") {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(part, "filename="))
|
||||
// 移除引号
|
||||
if len(name) > 2 && name[0] == '"' && name[len(name)-1] == '"' {
|
||||
name = name[1 : len(name)-1]
|
||||
}
|
||||
if dot := strings.LastIndex(name, "."); dot != -1 && dot+1 < len(name) {
|
||||
ext := strings.ToLower(name[dot+1:])
|
||||
if ext != "" {
|
||||
mt := GetMimeTypeByExtension(ext)
|
||||
if mt != "application/octet-stream" {
|
||||
return mt
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试从 URL 路径获取扩展名
|
||||
mt := guessMimeTypeFromURL(url)
|
||||
if mt != "application/octet-stream" {
|
||||
return mt
|
||||
}
|
||||
|
||||
// 4. 使用 http.DetectContentType 内容嗅探
|
||||
if len(fileBytes) > 0 {
|
||||
sniffed := http.DetectContentType(fileBytes)
|
||||
if sniffed != "" && sniffed != "application/octet-stream" {
|
||||
// 去除可能的 charset 参数
|
||||
if idx := strings.Index(sniffed, ";"); idx != -1 {
|
||||
sniffed = strings.TrimSpace(sniffed[:idx])
|
||||
}
|
||||
return sniffed
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 尝试作为图片解码获取格式
|
||||
if len(fileBytes) > 0 {
|
||||
if _, format, err := decodeImageConfig(fileBytes); err == nil && format != "" {
|
||||
return "image/" + strings.ToLower(format)
|
||||
}
|
||||
}
|
||||
|
||||
// 最终回退
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// loadFromBase64 从 base64 字符串加载文件
|
||||
func loadFromBase64(base64String string, providedMimeType string) (*types.CachedFileData, error) {
|
||||
var mimeType string
|
||||
var cleanBase64 string
|
||||
|
||||
// 处理 data: 前缀
|
||||
if strings.HasPrefix(base64String, "data:") {
|
||||
idx := strings.Index(base64String, ",")
|
||||
if idx != -1 {
|
||||
header := base64String[:idx]
|
||||
cleanBase64 = base64String[idx+1:]
|
||||
|
||||
if strings.Contains(header, ":") && strings.Contains(header, ";") {
|
||||
mimeStart := strings.Index(header, ":") + 1
|
||||
mimeEnd := strings.Index(header, ";")
|
||||
if mimeStart < mimeEnd {
|
||||
mimeType = header[mimeStart:mimeEnd]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cleanBase64 = base64String
|
||||
}
|
||||
} else {
|
||||
cleanBase64 = base64String
|
||||
}
|
||||
|
||||
if providedMimeType != "" {
|
||||
mimeType = providedMimeType
|
||||
}
|
||||
|
||||
decodedData, err := base64.StdEncoding.DecodeString(cleanBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 data: %w", err)
|
||||
}
|
||||
|
||||
base64Size := int64(len(cleanBase64))
|
||||
var cachedData *types.CachedFileData
|
||||
|
||||
if shouldUseDiskCache(base64Size) {
|
||||
diskPath, err := writeToDiskCache(cleanBase64)
|
||||
if err != nil {
|
||||
cachedData = types.NewMemoryCachedData(cleanBase64, mimeType, int64(len(decodedData)))
|
||||
} else {
|
||||
cachedData = types.NewDiskCachedData(diskPath, mimeType, int64(len(decodedData)))
|
||||
cachedData.DiskSize = base64Size
|
||||
cachedData.OnClose = func(size int64) {
|
||||
common.DecrementDiskFiles(size)
|
||||
}
|
||||
common.IncrementDiskFiles(base64Size)
|
||||
}
|
||||
} else {
|
||||
cachedData = types.NewMemoryCachedData(cleanBase64, mimeType, int64(len(decodedData)))
|
||||
}
|
||||
|
||||
if mimeType == "" || strings.HasPrefix(mimeType, "image/") {
|
||||
config, format, err := decodeImageConfig(decodedData)
|
||||
if err == nil {
|
||||
cachedData.ImageConfig = &config
|
||||
cachedData.ImageFormat = format
|
||||
if mimeType == "" {
|
||||
cachedData.MimeType = "image/" + format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cachedData, nil
|
||||
}
|
||||
|
||||
// GetImageConfig 获取图片配置
|
||||
func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
|
||||
cachedData, err := LoadFileSource(c, source, "get_image_config")
|
||||
if err != nil {
|
||||
return image.Config{}, "", err
|
||||
}
|
||||
|
||||
if cachedData.ImageConfig != nil {
|
||||
return *cachedData.ImageConfig, cachedData.ImageFormat, nil
|
||||
}
|
||||
|
||||
base64Str, err := cachedData.GetBase64Data()
|
||||
if err != nil {
|
||||
return image.Config{}, "", fmt.Errorf("failed to get base64 data: %w", err)
|
||||
}
|
||||
decodedData, err := base64.StdEncoding.DecodeString(base64Str)
|
||||
if err != nil {
|
||||
return image.Config{}, "", fmt.Errorf("failed to decode base64 for image config: %w", err)
|
||||
}
|
||||
|
||||
config, format, err := decodeImageConfig(decodedData)
|
||||
if err != nil {
|
||||
return image.Config{}, "", err
|
||||
}
|
||||
|
||||
cachedData.ImageConfig = &config
|
||||
cachedData.ImageFormat = format
|
||||
|
||||
return config, format, nil
|
||||
}
|
||||
|
||||
// GetBase64Data 获取 base64 编码的数据
|
||||
func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
|
||||
cachedData, err := LoadFileSource(c, source, reason...)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
base64Str, err := cachedData.GetBase64Data()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get base64 data: %w", err)
|
||||
}
|
||||
return base64Str, cachedData.MimeType, nil
|
||||
}
|
||||
|
||||
// GetMimeType 获取文件的 MIME 类型
|
||||
func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
|
||||
if source.HasCache() {
|
||||
return source.GetCache().MimeType, nil
|
||||
}
|
||||
|
||||
if source.IsURL() {
|
||||
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
|
||||
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
|
||||
return mimeType, nil
|
||||
}
|
||||
}
|
||||
|
||||
cachedData, err := LoadFileSource(c, source, "get_mime_type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cachedData.MimeType, nil
|
||||
}
|
||||
|
||||
// DetectFileType 检测文件类型
|
||||
func DetectFileType(mimeType string) types.FileType {
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
return types.FileTypeImage
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "audio/") {
|
||||
return types.FileTypeAudio
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "video/") {
|
||||
return types.FileTypeVideo
|
||||
}
|
||||
return types.FileTypeFile
|
||||
}
|
||||
|
||||
// decodeImageConfig 从字节数据解码图片配置
|
||||
func decodeImageConfig(data []byte) (image.Config, string, error) {
|
||||
reader := bytes.NewReader(data)
|
||||
|
||||
config, format, err := image.DecodeConfig(reader)
|
||||
if err == nil {
|
||||
return config, format, nil
|
||||
}
|
||||
|
||||
reader.Seek(0, io.SeekStart)
|
||||
config, err = webp.DecodeConfig(reader)
|
||||
if err == nil {
|
||||
return config, "webp", nil
|
||||
}
|
||||
|
||||
return image.Config{}, "", fmt.Errorf("failed to decode image config: unsupported format")
|
||||
}
|
||||
|
||||
// guessMimeTypeFromURL 从 URL 猜测 MIME 类型
|
||||
func guessMimeTypeFromURL(url string) string {
|
||||
cleanedURL := url
|
||||
if q := strings.Index(cleanedURL, "?"); q != -1 {
|
||||
cleanedURL = cleanedURL[:q]
|
||||
}
|
||||
|
||||
if slash := strings.LastIndex(cleanedURL, "/"); slash != -1 && slash+1 < len(cleanedURL) {
|
||||
last := cleanedURL[slash+1:]
|
||||
if dot := strings.LastIndex(last, "."); dot != -1 && dot+1 < len(last) {
|
||||
ext := strings.ToLower(last[dot+1:])
|
||||
return GetMimeTypeByExtension(ext)
|
||||
}
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
140
service/funding_source.go
Normal file
140
service/funding_source.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FundingSource — 资金来源接口(钱包 or 订阅)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FundingSource 抽象了预扣费的资金来源。
|
||||
type FundingSource interface {
|
||||
// Source 返回资金来源标识:"wallet" 或 "subscription"
|
||||
Source() string
|
||||
// PreConsume 从该资金来源预扣 amount 额度
|
||||
PreConsume(amount int) error
|
||||
// Settle 根据差额调整资金来源(正数补扣,负数退还)
|
||||
Settle(delta int) error
|
||||
// Refund 退还所有预扣费
|
||||
Refund() error
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WalletFunding — 钱包资金来源实现
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WalletFunding struct {
|
||||
userId int
|
||||
consumed int // 实际预扣的用户额度
|
||||
}
|
||||
|
||||
func (w *WalletFunding) Source() string { return BillingSourceWallet }
|
||||
|
||||
func (w *WalletFunding) PreConsume(amount int) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
if err := model.DecreaseUserQuota(w.userId, amount); err != nil {
|
||||
return err
|
||||
}
|
||||
w.consumed = amount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WalletFunding) Settle(delta int) error {
|
||||
if delta == 0 {
|
||||
return nil
|
||||
}
|
||||
if delta > 0 {
|
||||
return model.DecreaseUserQuota(w.userId, delta)
|
||||
}
|
||||
return model.IncreaseUserQuota(w.userId, -delta, false)
|
||||
}
|
||||
|
||||
func (w *WalletFunding) Refund() error {
|
||||
if w.consumed <= 0 {
|
||||
return nil
|
||||
}
|
||||
// IncreaseUserQuota 是 quota += N 的非幂等操作,不能重试,否则会多退额度。
|
||||
// 订阅的 RefundSubscriptionPreConsume 有 requestId 幂等保护所以可以重试。
|
||||
return model.IncreaseUserQuota(w.userId, w.consumed, false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SubscriptionFunding — 订阅资金来源实现
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SubscriptionFunding struct {
|
||||
requestId string
|
||||
userId int
|
||||
modelName string
|
||||
amount int64 // 预扣的订阅额度(subConsume)
|
||||
group string
|
||||
subscriptionId int
|
||||
preConsumed int64
|
||||
// 以下字段在 PreConsume 成功后填充,供 RelayInfo 同步使用
|
||||
AmountTotal int64
|
||||
AmountUsedAfter int64
|
||||
PlanId int
|
||||
PlanTitle string
|
||||
}
|
||||
|
||||
func (s *SubscriptionFunding) Source() string { return BillingSourceSubscription }
|
||||
|
||||
func (s *SubscriptionFunding) PreConsume(_ int) error {
|
||||
// amount 参数被忽略,使用内部 s.amount(已在构造时根据 preConsumedQuota 计算)
|
||||
res, err := model.PreConsumeUserSubscription(s.requestId, s.userId, s.modelName, 0, s.amount, s.group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.subscriptionId = res.UserSubscriptionId
|
||||
s.preConsumed = res.PreConsumed
|
||||
s.AmountTotal = res.AmountTotal
|
||||
s.AmountUsedAfter = res.AmountUsedAfter
|
||||
// 获取订阅计划信息
|
||||
if planInfo, err := model.GetSubscriptionPlanInfoByUserSubscriptionId(res.UserSubscriptionId); err == nil && planInfo != nil {
|
||||
s.PlanId = planInfo.PlanId
|
||||
s.PlanTitle = planInfo.PlanTitle
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SubscriptionFunding) Settle(delta int) error {
|
||||
if delta == 0 {
|
||||
return nil
|
||||
}
|
||||
return model.PostConsumeUserSubscriptionDelta(s.subscriptionId, int64(delta))
|
||||
}
|
||||
|
||||
func (s *SubscriptionFunding) Refund() error {
|
||||
if s.preConsumed <= 0 {
|
||||
return nil
|
||||
}
|
||||
return refundWithRetry(func() error {
|
||||
return model.RefundSubscriptionPreConsume(s.requestId)
|
||||
})
|
||||
}
|
||||
|
||||
// refundWithRetry 尝试多次执行退款操作以提高成功率,只能用于基于事务的退款函数!!!!!!
|
||||
// try to refund with retries, only for refund functions based on transactions!!!
|
||||
func refundWithRetry(fn func() error) error {
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
const maxAttempts = 3
|
||||
var lastErr error
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
if err := fn(); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
if i < maxAttempts-1 {
|
||||
time.Sleep(time.Duration(200*(i+1)) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
65
service/group.go
Normal file
65
service/group.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
)
|
||||
|
||||
func GetUserUsableGroups(userGroup string) map[string]string {
|
||||
groupsCopy := setting.GetUserUsableGroupsCopy()
|
||||
if userGroup != "" {
|
||||
specialSettings, b := ratio_setting.GetGroupRatioSetting().GroupSpecialUsableGroup.Get(userGroup)
|
||||
if b {
|
||||
// 处理特殊可用分组
|
||||
for specialGroup, desc := range specialSettings {
|
||||
if strings.HasPrefix(specialGroup, "-:") {
|
||||
// 移除分组
|
||||
groupToRemove := strings.TrimPrefix(specialGroup, "-:")
|
||||
delete(groupsCopy, groupToRemove)
|
||||
} else if strings.HasPrefix(specialGroup, "+:") {
|
||||
// 添加分组
|
||||
groupToAdd := strings.TrimPrefix(specialGroup, "+:")
|
||||
groupsCopy[groupToAdd] = desc
|
||||
} else {
|
||||
// 直接添加分组
|
||||
groupsCopy[specialGroup] = desc
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果userGroup不在UserUsableGroups中,返回UserUsableGroups + userGroup
|
||||
if _, ok := groupsCopy[userGroup]; !ok {
|
||||
groupsCopy[userGroup] = "用户分组"
|
||||
}
|
||||
}
|
||||
return groupsCopy
|
||||
}
|
||||
|
||||
func GroupInUserUsableGroups(userGroup, groupName string) bool {
|
||||
_, ok := GetUserUsableGroups(userGroup)[groupName]
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetUserAutoGroup 根据用户分组获取自动分组设置
|
||||
func GetUserAutoGroup(userGroup string) []string {
|
||||
groups := GetUserUsableGroups(userGroup)
|
||||
autoGroups := make([]string, 0)
|
||||
for _, group := range setting.GetAutoGroups() {
|
||||
if _, ok := groups[group]; ok {
|
||||
autoGroups = append(autoGroups, group)
|
||||
}
|
||||
}
|
||||
return autoGroups
|
||||
}
|
||||
|
||||
// GetUserGroupRatio 获取用户使用某个分组的倍率
|
||||
// userGroup 用户分组
|
||||
// group 需要获取倍率的分组
|
||||
func GetUserGroupRatio(userGroup, group string) float64 {
|
||||
ratio, ok := ratio_setting.GetGroupGroupRatio(userGroup, group)
|
||||
if ok {
|
||||
return ratio
|
||||
}
|
||||
return ratio_setting.GetGroupRatio(group)
|
||||
}
|
||||
61
service/http.go
Normal file
61
service/http.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CloseResponseBodyGracefully(httpResponse *http.Response) {
|
||||
if httpResponse == nil || httpResponse.Body == nil {
|
||||
return
|
||||
}
|
||||
err := httpResponse.Body.Close()
|
||||
if err != nil {
|
||||
common.SysError("failed to close response body: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) {
|
||||
if c.Writer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
body := io.NopCloser(bytes.NewBuffer(data))
|
||||
|
||||
// We shouldn't set the header before we parse the response body, because the parse part may fail.
|
||||
// And then we will have to send an error response, but in this case, the header has already been set.
|
||||
// So the httpClient will be confused by the response.
|
||||
// For example, Postman will report error, and we cannot check the response at all.
|
||||
if src != nil {
|
||||
for k, v := range src.Header {
|
||||
// avoid setting Content-Length
|
||||
if k == "Content-Length" {
|
||||
continue
|
||||
}
|
||||
c.Writer.Header().Set(k, v[0])
|
||||
}
|
||||
}
|
||||
|
||||
// set Content-Length header manually BEFORE calling WriteHeader
|
||||
c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||
|
||||
// Write header with status code (this sends the headers)
|
||||
if src != nil {
|
||||
c.Writer.WriteHeader(src.StatusCode)
|
||||
} else {
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
_, err := io.Copy(c.Writer, body)
|
||||
if err != nil {
|
||||
logger.LogError(c, fmt.Sprintf("failed to copy response body: %s", err.Error()))
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
169
service/http_client.go
Normal file
169
service/http_client.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
var (
|
||||
httpClient *http.Client
|
||||
proxyClientLock sync.Mutex
|
||||
proxyClients = make(map[string]*http.Client)
|
||||
)
|
||||
|
||||
func checkRedirect(req *http.Request, via []*http.Request) error {
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
urlStr := req.URL.String()
|
||||
if err := common.ValidateURLWithFetchSetting(urlStr, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||
return fmt.Errorf("redirect to %s blocked: %v", urlStr, err)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitHttpClient() {
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: common.RelayMaxIdleConns,
|
||||
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
|
||||
ForceAttemptHTTP2: true,
|
||||
Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars
|
||||
}
|
||||
if common.TLSInsecureSkipVerify {
|
||||
transport.TLSClientConfig = common.InsecureTLSConfig
|
||||
}
|
||||
|
||||
if common.RelayTimeout == 0 {
|
||||
httpClient = &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
} else {
|
||||
httpClient = &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: time.Duration(common.RelayTimeout) * time.Second,
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetHttpClient() *http.Client {
|
||||
return httpClient
|
||||
}
|
||||
|
||||
// GetHttpClientWithProxy returns the default client or a proxy-enabled one when proxyURL is provided.
|
||||
func GetHttpClientWithProxy(proxyURL string) (*http.Client, error) {
|
||||
if proxyURL == "" {
|
||||
return GetHttpClient(), nil
|
||||
}
|
||||
return NewProxyHttpClient(proxyURL)
|
||||
}
|
||||
|
||||
// ResetProxyClientCache 清空代理客户端缓存,确保下次使用时重新初始化
|
||||
func ResetProxyClientCache() {
|
||||
proxyClientLock.Lock()
|
||||
defer proxyClientLock.Unlock()
|
||||
for _, client := range proxyClients {
|
||||
if transport, ok := client.Transport.(*http.Transport); ok && transport != nil {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
proxyClients = make(map[string]*http.Client)
|
||||
}
|
||||
|
||||
// NewProxyHttpClient 创建支持代理的 HTTP 客户端
|
||||
func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
||||
if proxyURL == "" {
|
||||
if client := GetHttpClient(); client != nil {
|
||||
return client, nil
|
||||
}
|
||||
return http.DefaultClient, nil
|
||||
}
|
||||
|
||||
proxyClientLock.Lock()
|
||||
if client, ok := proxyClients[proxyURL]; ok {
|
||||
proxyClientLock.Unlock()
|
||||
return client, nil
|
||||
}
|
||||
proxyClientLock.Unlock()
|
||||
|
||||
parsedURL, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch parsedURL.Scheme {
|
||||
case "http", "https":
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: common.RelayMaxIdleConns,
|
||||
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
|
||||
ForceAttemptHTTP2: true,
|
||||
Proxy: http.ProxyURL(parsedURL),
|
||||
}
|
||||
if common.TLSInsecureSkipVerify {
|
||||
transport.TLSClientConfig = common.InsecureTLSConfig
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
|
||||
proxyClientLock.Lock()
|
||||
proxyClients[proxyURL] = client
|
||||
proxyClientLock.Unlock()
|
||||
return client, nil
|
||||
|
||||
case "socks5", "socks5h":
|
||||
// 获取认证信息
|
||||
var auth *proxy.Auth
|
||||
if parsedURL.User != nil {
|
||||
auth = &proxy.Auth{
|
||||
User: parsedURL.User.Username(),
|
||||
Password: "",
|
||||
}
|
||||
if password, ok := parsedURL.User.Password(); ok {
|
||||
auth.Password = password
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 SOCKS5 代理拨号器
|
||||
// proxy.SOCKS5 使用 tcp 参数,所有 TCP 连接包括 DNS 查询都将通过代理进行。行为与 socks5h 相同
|
||||
dialer, err := proxy.SOCKS5("tcp", parsedURL.Host, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: common.RelayMaxIdleConns,
|
||||
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
|
||||
ForceAttemptHTTP2: true,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
}
|
||||
if common.TLSInsecureSkipVerify {
|
||||
transport.TLSClientConfig = common.InsecureTLSConfig
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: transport, CheckRedirect: checkRedirect}
|
||||
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
|
||||
proxyClientLock.Lock()
|
||||
proxyClients[proxyURL] = client
|
||||
proxyClientLock.Unlock()
|
||||
return client, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s, must be http, https, socks5 or socks5h", parsedURL.Scheme)
|
||||
}
|
||||
}
|
||||
178
service/image.go
Normal file
178
service/image.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// return image.Config, format, clean base64 string, error
|
||||
func DecodeBase64ImageData(base64String string) (image.Config, string, string, error) {
|
||||
// 去除base64数据的URL前缀(如果有)
|
||||
if idx := strings.Index(base64String, ","); idx != -1 {
|
||||
base64String = base64String[idx+1:]
|
||||
}
|
||||
|
||||
if len(base64String) == 0 {
|
||||
return image.Config{}, "", "", errors.New("base64 string is empty")
|
||||
}
|
||||
|
||||
// 将base64字符串解码为字节切片
|
||||
decodedData, err := base64.StdEncoding.DecodeString(base64String)
|
||||
if err != nil {
|
||||
fmt.Println("Error: Failed to decode base64 string")
|
||||
return image.Config{}, "", "", fmt.Errorf("failed to decode base64 string: %s", err.Error())
|
||||
}
|
||||
|
||||
// 创建一个bytes.Buffer用于存储解码后的数据
|
||||
reader := bytes.NewReader(decodedData)
|
||||
config, format, err := getImageConfig(reader)
|
||||
return config, format, base64String, err
|
||||
}
|
||||
|
||||
func DecodeBase64FileData(base64String string) (string, string, error) {
|
||||
var mimeType string
|
||||
var idx int
|
||||
idx = strings.Index(base64String, ",")
|
||||
if idx == -1 {
|
||||
_, file_type, base64, err := DecodeBase64ImageData(base64String)
|
||||
return "image/" + file_type, base64, err
|
||||
}
|
||||
mimeType = base64String[:idx]
|
||||
base64String = base64String[idx+1:]
|
||||
idx = strings.Index(mimeType, ";")
|
||||
if idx == -1 {
|
||||
_, file_type, base64, err := DecodeBase64ImageData(base64String)
|
||||
return "image/" + file_type, base64, err
|
||||
}
|
||||
mimeType = mimeType[:idx]
|
||||
idx = strings.Index(mimeType, ":")
|
||||
if idx == -1 {
|
||||
_, file_type, base64, err := DecodeBase64ImageData(base64String)
|
||||
return "image/" + file_type, base64, err
|
||||
}
|
||||
mimeType = mimeType[idx+1:]
|
||||
return mimeType, base64String, nil
|
||||
}
|
||||
|
||||
// GetImageFromUrl 获取图片的类型和base64编码的数据
|
||||
func GetImageFromUrl(url string) (mimeType string, data string, err error) {
|
||||
resp, err := DoDownloadRequest(url)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to download image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check HTTP status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("failed to download image: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType != "application/octet-stream" && !strings.HasPrefix(contentType, "image/") {
|
||||
return "", "", fmt.Errorf("invalid content type: %s, required image/*", contentType)
|
||||
}
|
||||
maxImageSize := int64(constant.MaxFileDownloadMB * 1024 * 1024)
|
||||
|
||||
// Check Content-Length if available
|
||||
if resp.ContentLength > maxImageSize {
|
||||
return "", "", fmt.Errorf("image size %d exceeds maximum allowed size of %d bytes", resp.ContentLength, maxImageSize)
|
||||
}
|
||||
|
||||
// Use LimitReader to prevent reading oversized images
|
||||
limitReader := io.LimitReader(resp.Body, maxImageSize)
|
||||
buffer := &bytes.Buffer{}
|
||||
|
||||
written, err := io.Copy(buffer, limitReader)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read image data: %w", err)
|
||||
}
|
||||
if written >= maxImageSize {
|
||||
return "", "", fmt.Errorf("image size exceeds maximum allowed size of %d bytes", maxImageSize)
|
||||
}
|
||||
|
||||
data = base64.StdEncoding.EncodeToString(buffer.Bytes())
|
||||
mimeType = contentType
|
||||
|
||||
// Handle application/octet-stream type
|
||||
if mimeType == "application/octet-stream" {
|
||||
_, format, _, err := DecodeBase64ImageData(data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
mimeType = "image/" + format
|
||||
}
|
||||
|
||||
return mimeType, data, nil
|
||||
}
|
||||
|
||||
func DecodeUrlImageData(imageUrl string) (image.Config, string, error) {
|
||||
response, err := DoDownloadRequest(imageUrl)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("fail to get image from url: %s", err.Error()))
|
||||
return image.Config{}, "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
err = errors.New(fmt.Sprintf("fail to get image from url: %s", response.Status))
|
||||
return image.Config{}, "", err
|
||||
}
|
||||
|
||||
mimeType := response.Header.Get("Content-Type")
|
||||
|
||||
if mimeType != "application/octet-stream" && !strings.HasPrefix(mimeType, "image/") {
|
||||
return image.Config{}, "", fmt.Errorf("invalid content type: %s, required image/*", mimeType)
|
||||
}
|
||||
|
||||
var readData []byte
|
||||
for _, limit := range []int64{1024 * 8, 1024 * 24, 1024 * 64} {
|
||||
common.SysLog(fmt.Sprintf("try to decode image config with limit: %d", limit))
|
||||
|
||||
// 从response.Body读取更多的数据直到达到当前的限制
|
||||
additionalData := make([]byte, limit-int64(len(readData)))
|
||||
n, _ := io.ReadFull(response.Body, additionalData)
|
||||
readData = append(readData, additionalData[:n]...)
|
||||
|
||||
// 使用io.MultiReader组合已经读取的数据和response.Body
|
||||
limitReader := io.MultiReader(bytes.NewReader(readData), response.Body)
|
||||
|
||||
var config image.Config
|
||||
var format string
|
||||
config, format, err = getImageConfig(limitReader)
|
||||
if err == nil {
|
||||
return config, format, nil
|
||||
}
|
||||
}
|
||||
|
||||
return image.Config{}, "", err // 返回最后一个错误
|
||||
}
|
||||
|
||||
func getImageConfig(reader io.Reader) (image.Config, string, error) {
|
||||
// 读取图片的头部信息来获取图片尺寸
|
||||
config, format, err := image.DecodeConfig(reader)
|
||||
if err != nil {
|
||||
err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
|
||||
common.SysLog(err.Error())
|
||||
config, err = webp.DecodeConfig(reader)
|
||||
if err != nil {
|
||||
err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
|
||||
common.SysLog(err.Error())
|
||||
}
|
||||
format = "webp"
|
||||
}
|
||||
if err != nil {
|
||||
return image.Config{}, "", err
|
||||
}
|
||||
return config, format, nil
|
||||
}
|
||||
236
service/log_info_generate.go
Normal file
236
service/log_info_generate.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||
if other == nil {
|
||||
return
|
||||
}
|
||||
if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil {
|
||||
if path := ctx.Request.URL.Path; path != "" {
|
||||
other["request_path"] = path
|
||||
return
|
||||
}
|
||||
}
|
||||
if relayInfo != nil && relayInfo.RequestURLPath != "" {
|
||||
path := relayInfo.RequestURLPath
|
||||
if idx := strings.Index(path, "?"); idx != -1 {
|
||||
path = path[:idx]
|
||||
}
|
||||
other["request_path"] = path
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
|
||||
cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
|
||||
other := make(map[string]interface{})
|
||||
other["model_ratio"] = modelRatio
|
||||
other["group_ratio"] = groupRatio
|
||||
other["completion_ratio"] = completionRatio
|
||||
other["cache_tokens"] = cacheTokens
|
||||
other["cache_ratio"] = cacheRatio
|
||||
other["model_price"] = modelPrice
|
||||
other["user_group_ratio"] = userGroupRatio
|
||||
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
|
||||
if relayInfo.ReasoningEffort != "" {
|
||||
other["reasoning_effort"] = relayInfo.ReasoningEffort
|
||||
}
|
||||
if relayInfo.IsModelMapped {
|
||||
other["is_model_mapped"] = true
|
||||
other["upstream_model_name"] = relayInfo.UpstreamModelName
|
||||
}
|
||||
|
||||
isSystemPromptOverwritten := common.GetContextKeyBool(ctx, constant.ContextKeySystemPromptOverride)
|
||||
if isSystemPromptOverwritten {
|
||||
other["is_system_prompt_overwritten"] = true
|
||||
}
|
||||
|
||||
adminInfo := make(map[string]interface{})
|
||||
adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
|
||||
isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey)
|
||||
if isMultiKey {
|
||||
adminInfo["is_multi_key"] = true
|
||||
adminInfo["multi_key_index"] = common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex)
|
||||
}
|
||||
|
||||
isLocalCountTokens := common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens)
|
||||
if isLocalCountTokens {
|
||||
adminInfo["local_count_tokens"] = isLocalCountTokens
|
||||
}
|
||||
|
||||
AppendChannelAffinityAdminInfo(ctx, adminInfo)
|
||||
|
||||
other["admin_info"] = adminInfo
|
||||
appendRequestPath(ctx, relayInfo, other)
|
||||
appendRequestConversionChain(relayInfo, other)
|
||||
appendFinalRequestFormat(relayInfo, other)
|
||||
appendBillingInfo(relayInfo, other)
|
||||
appendParamOverrideInfo(relayInfo, other)
|
||||
return other
|
||||
}
|
||||
|
||||
func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||
if relayInfo == nil || other == nil || len(relayInfo.ParamOverrideAudit) == 0 {
|
||||
return
|
||||
}
|
||||
other["po"] = relayInfo.ParamOverrideAudit
|
||||
}
|
||||
|
||||
func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||
if relayInfo == nil || other == nil {
|
||||
return
|
||||
}
|
||||
// billing_source: "wallet" or "subscription"
|
||||
if relayInfo.BillingSource != "" {
|
||||
other["billing_source"] = relayInfo.BillingSource
|
||||
}
|
||||
if relayInfo.UserSetting.BillingPreference != "" {
|
||||
other["billing_preference"] = relayInfo.UserSetting.BillingPreference
|
||||
}
|
||||
if relayInfo.BillingSource == "subscription" {
|
||||
if relayInfo.SubscriptionId != 0 {
|
||||
other["subscription_id"] = relayInfo.SubscriptionId
|
||||
}
|
||||
if relayInfo.SubscriptionPreConsumed > 0 {
|
||||
other["subscription_pre_consumed"] = relayInfo.SubscriptionPreConsumed
|
||||
}
|
||||
// post_delta: settlement delta applied after actual usage is known (can be negative for refund)
|
||||
if relayInfo.SubscriptionPostDelta != 0 {
|
||||
other["subscription_post_delta"] = relayInfo.SubscriptionPostDelta
|
||||
}
|
||||
if relayInfo.SubscriptionPlanId != 0 {
|
||||
other["subscription_plan_id"] = relayInfo.SubscriptionPlanId
|
||||
}
|
||||
if relayInfo.SubscriptionPlanTitle != "" {
|
||||
other["subscription_plan_title"] = relayInfo.SubscriptionPlanTitle
|
||||
}
|
||||
// Compute "this request" subscription consumed + remaining
|
||||
consumed := relayInfo.SubscriptionPreConsumed + relayInfo.SubscriptionPostDelta
|
||||
usedFinal := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta
|
||||
if consumed < 0 {
|
||||
consumed = 0
|
||||
}
|
||||
if usedFinal < 0 {
|
||||
usedFinal = 0
|
||||
}
|
||||
if relayInfo.SubscriptionAmountTotal > 0 {
|
||||
remain := relayInfo.SubscriptionAmountTotal - usedFinal
|
||||
if remain < 0 {
|
||||
remain = 0
|
||||
}
|
||||
other["subscription_total"] = relayInfo.SubscriptionAmountTotal
|
||||
other["subscription_used"] = usedFinal
|
||||
other["subscription_remain"] = remain
|
||||
}
|
||||
if consumed > 0 {
|
||||
other["subscription_consumed"] = consumed
|
||||
}
|
||||
// Wallet quota is not deducted when billed from subscription.
|
||||
other["wallet_quota_deducted"] = 0
|
||||
}
|
||||
}
|
||||
|
||||
func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||
if relayInfo == nil || other == nil {
|
||||
return
|
||||
}
|
||||
if len(relayInfo.RequestConversionChain) == 0 {
|
||||
return
|
||||
}
|
||||
chain := make([]string, 0, len(relayInfo.RequestConversionChain))
|
||||
for _, f := range relayInfo.RequestConversionChain {
|
||||
switch f {
|
||||
case types.RelayFormatOpenAI:
|
||||
chain = append(chain, "OpenAI Compatible")
|
||||
case types.RelayFormatClaude:
|
||||
chain = append(chain, "Claude Messages")
|
||||
case types.RelayFormatGemini:
|
||||
chain = append(chain, "Google Gemini")
|
||||
case types.RelayFormatOpenAIResponses:
|
||||
chain = append(chain, "OpenAI Responses")
|
||||
default:
|
||||
chain = append(chain, string(f))
|
||||
}
|
||||
}
|
||||
if len(chain) == 0 {
|
||||
return
|
||||
}
|
||||
other["request_conversion"] = chain
|
||||
}
|
||||
|
||||
func appendFinalRequestFormat(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||
if relayInfo == nil || other == nil {
|
||||
return
|
||||
}
|
||||
if relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
|
||||
// claude indicates the final upstream request format is Claude Messages.
|
||||
// Frontend log rendering uses this to keep the original Claude input display.
|
||||
other["claude"] = true
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
|
||||
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
|
||||
info["ws"] = true
|
||||
info["audio_input"] = usage.InputTokenDetails.AudioTokens
|
||||
info["audio_output"] = usage.OutputTokenDetails.AudioTokens
|
||||
info["text_input"] = usage.InputTokenDetails.TextTokens
|
||||
info["text_output"] = usage.OutputTokenDetails.TextTokens
|
||||
info["audio_ratio"] = audioRatio
|
||||
info["audio_completion_ratio"] = audioCompletionRatio
|
||||
return info
|
||||
}
|
||||
|
||||
func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
|
||||
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
|
||||
info["audio"] = true
|
||||
info["audio_input"] = usage.PromptTokensDetails.AudioTokens
|
||||
info["audio_output"] = usage.CompletionTokenDetails.AudioTokens
|
||||
info["text_input"] = usage.PromptTokensDetails.TextTokens
|
||||
info["text_output"] = usage.CompletionTokenDetails.TextTokens
|
||||
info["audio_ratio"] = audioRatio
|
||||
info["audio_completion_ratio"] = audioCompletionRatio
|
||||
return info
|
||||
}
|
||||
|
||||
func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
|
||||
cacheTokens int, cacheRatio float64,
|
||||
cacheCreationTokens int, cacheCreationRatio float64,
|
||||
cacheCreationTokens5m int, cacheCreationRatio5m float64,
|
||||
cacheCreationTokens1h int, cacheCreationRatio1h float64,
|
||||
modelPrice float64, userGroupRatio float64) map[string]interface{} {
|
||||
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
|
||||
info["claude"] = true
|
||||
info["cache_creation_tokens"] = cacheCreationTokens
|
||||
info["cache_creation_ratio"] = cacheCreationRatio
|
||||
if cacheCreationTokens5m != 0 {
|
||||
info["cache_creation_tokens_5m"] = cacheCreationTokens5m
|
||||
info["cache_creation_ratio_5m"] = cacheCreationRatio5m
|
||||
}
|
||||
if cacheCreationTokens1h != 0 {
|
||||
info["cache_creation_tokens_1h"] = cacheCreationTokens1h
|
||||
info["cache_creation_ratio_1h"] = cacheCreationRatio1h
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.PriceData) map[string]interface{} {
|
||||
other := make(map[string]interface{})
|
||||
other["model_price"] = priceData.ModelPrice
|
||||
other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio
|
||||
if priceData.GroupRatioInfo.HasSpecialRatio {
|
||||
other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio
|
||||
}
|
||||
appendRequestPath(nil, relayInfo, other)
|
||||
return other
|
||||
}
|
||||
259
service/midjourney.go
Normal file
259
service/midjourney.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CovertMjpActionToModelName(mjAction string) string {
|
||||
modelName := "mj_" + strings.ToLower(mjAction)
|
||||
if mjAction == constant.MjActionSwapFace {
|
||||
modelName = "swap_face"
|
||||
}
|
||||
return modelName
|
||||
}
|
||||
|
||||
func GetMjRequestModel(relayMode int, midjRequest *dto.MidjourneyRequest) (string, *dto.MidjourneyResponse, bool) {
|
||||
action := ""
|
||||
if relayMode == relayconstant.RelayModeMidjourneyAction {
|
||||
// plus request
|
||||
err := CoverPlusActionToNormalAction(midjRequest)
|
||||
if err != nil {
|
||||
return "", err, false
|
||||
}
|
||||
action = midjRequest.Action
|
||||
} else {
|
||||
switch relayMode {
|
||||
case relayconstant.RelayModeMidjourneyImagine:
|
||||
action = constant.MjActionImagine
|
||||
case relayconstant.RelayModeMidjourneyVideo:
|
||||
action = constant.MjActionVideo
|
||||
case relayconstant.RelayModeMidjourneyEdits:
|
||||
action = constant.MjActionEdits
|
||||
case relayconstant.RelayModeMidjourneyDescribe:
|
||||
action = constant.MjActionDescribe
|
||||
case relayconstant.RelayModeMidjourneyBlend:
|
||||
action = constant.MjActionBlend
|
||||
case relayconstant.RelayModeMidjourneyShorten:
|
||||
action = constant.MjActionShorten
|
||||
case relayconstant.RelayModeMidjourneyChange:
|
||||
action = midjRequest.Action
|
||||
case relayconstant.RelayModeMidjourneyModal:
|
||||
action = constant.MjActionModal
|
||||
case relayconstant.RelayModeSwapFace:
|
||||
action = constant.MjActionSwapFace
|
||||
case relayconstant.RelayModeMidjourneyUpload:
|
||||
action = constant.MjActionUpload
|
||||
case relayconstant.RelayModeMidjourneySimpleChange:
|
||||
params := ConvertSimpleChangeParams(midjRequest.Content)
|
||||
if params == nil {
|
||||
return "", MidjourneyErrorWrapper(constant.MjRequestError, "invalid_request"), false
|
||||
}
|
||||
action = params.Action
|
||||
case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition, relayconstant.RelayModeMidjourneyNotify:
|
||||
return "", nil, true
|
||||
default:
|
||||
return "", MidjourneyErrorWrapper(constant.MjRequestError, "unknown_relay_action"), false
|
||||
}
|
||||
}
|
||||
modelName := CovertMjpActionToModelName(action)
|
||||
return modelName, nil, true
|
||||
}
|
||||
|
||||
func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.MidjourneyResponse {
|
||||
// "customId": "MJ::JOB::upsample::2::3dbbd469-36af-4a0f-8f02-df6c579e7011"
|
||||
customId := midjRequest.CustomId
|
||||
if customId == "" {
|
||||
return MidjourneyErrorWrapper(constant.MjRequestError, "custom_id_is_required")
|
||||
}
|
||||
splits := strings.Split(customId, "::")
|
||||
var action string
|
||||
if splits[1] == "JOB" {
|
||||
action = splits[2]
|
||||
} else {
|
||||
action = splits[1]
|
||||
}
|
||||
|
||||
if action == "" {
|
||||
return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action")
|
||||
}
|
||||
if strings.Contains(action, "upsample") {
|
||||
index, err := strconv.Atoi(splits[3])
|
||||
if err != nil {
|
||||
return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed")
|
||||
}
|
||||
midjRequest.Index = index
|
||||
midjRequest.Action = constant.MjActionUpscale
|
||||
} else if strings.Contains(action, "variation") {
|
||||
midjRequest.Index = 1
|
||||
if action == "variation" {
|
||||
index, err := strconv.Atoi(splits[3])
|
||||
if err != nil {
|
||||
return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed")
|
||||
}
|
||||
midjRequest.Index = index
|
||||
midjRequest.Action = constant.MjActionVariation
|
||||
} else if action == "low_variation" {
|
||||
midjRequest.Action = constant.MjActionLowVariation
|
||||
} else if action == "high_variation" {
|
||||
midjRequest.Action = constant.MjActionHighVariation
|
||||
}
|
||||
} else if strings.Contains(action, "pan") {
|
||||
midjRequest.Action = constant.MjActionPan
|
||||
midjRequest.Index = 1
|
||||
} else if strings.Contains(action, "reroll") {
|
||||
midjRequest.Action = constant.MjActionReRoll
|
||||
midjRequest.Index = 1
|
||||
} else if action == "Outpaint" {
|
||||
midjRequest.Action = constant.MjActionZoom
|
||||
midjRequest.Index = 1
|
||||
} else if action == "CustomZoom" {
|
||||
midjRequest.Action = constant.MjActionCustomZoom
|
||||
midjRequest.Index = 1
|
||||
} else if action == "Inpaint" {
|
||||
midjRequest.Action = constant.MjActionInPaint
|
||||
midjRequest.Index = 1
|
||||
} else {
|
||||
return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action:"+customId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ConvertSimpleChangeParams(content string) *dto.MidjourneyRequest {
|
||||
split := strings.Split(content, " ")
|
||||
if len(split) != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
action := strings.ToLower(split[1])
|
||||
changeParams := &dto.MidjourneyRequest{}
|
||||
changeParams.TaskId = split[0]
|
||||
|
||||
if action[0] == 'u' {
|
||||
changeParams.Action = "UPSCALE"
|
||||
} else if action[0] == 'v' {
|
||||
changeParams.Action = "VARIATION"
|
||||
} else if action == "r" {
|
||||
changeParams.Action = "REROLL"
|
||||
return changeParams
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
index, err := strconv.Atoi(action[1:2])
|
||||
if err != nil || index < 1 || index > 4 {
|
||||
return nil
|
||||
}
|
||||
changeParams.Index = index
|
||||
return changeParams
|
||||
}
|
||||
|
||||
func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestURL string) (*dto.MidjourneyResponseWithStatusCode, []byte, error) {
|
||||
var nullBytes []byte
|
||||
//var requestBody io.Reader
|
||||
//requestBody = c.Request.Body
|
||||
// read request body to json, delete accountFilter and notifyHook
|
||||
var mapResult map[string]interface{}
|
||||
// if get request, no need to read request body
|
||||
if c.Request.Method != "GET" {
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&mapResult)
|
||||
if err != nil {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_request_body_failed", http.StatusInternalServerError), nullBytes, err
|
||||
}
|
||||
if !setting.MjAccountFilterEnabled {
|
||||
delete(mapResult, "accountFilter")
|
||||
}
|
||||
if !setting.MjNotifyEnabled {
|
||||
delete(mapResult, "notifyHook")
|
||||
}
|
||||
//req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
||||
// make new request with mapResult
|
||||
}
|
||||
if setting.MjModeClearEnabled {
|
||||
if prompt, ok := mapResult["prompt"].(string); ok {
|
||||
prompt = strings.Replace(prompt, "--fast", "", -1)
|
||||
prompt = strings.Replace(prompt, "--relax", "", -1)
|
||||
prompt = strings.Replace(prompt, "--turbo", "", -1)
|
||||
|
||||
mapResult["prompt"] = prompt
|
||||
}
|
||||
}
|
||||
reqBody, err := json.Marshal(mapResult)
|
||||
if err != nil {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "marshal_request_body_failed", http.StatusInternalServerError), nullBytes, err
|
||||
}
|
||||
req, err := http.NewRequest(c.Request.Method, fullRequestURL, strings.NewReader(string(reqBody)))
|
||||
if err != nil {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "create_request_failed", http.StatusInternalServerError), nullBytes, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
// 使用带有超时的 context 创建新的请求
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
||||
req.Header.Set("Accept", c.Request.Header.Get("Accept"))
|
||||
auth := common.GetContextKeyString(c, constant.ContextKeyChannelKey)
|
||||
if auth != "" {
|
||||
auth = strings.TrimPrefix(auth, "Bearer ")
|
||||
req.Header.Set("mj-api-secret", auth)
|
||||
}
|
||||
defer cancel()
|
||||
resp, err := GetHttpClient().Do(req)
|
||||
if err != nil {
|
||||
common.SysLog("do request failed: " + err.Error())
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "do_request_failed", http.StatusInternalServerError), nullBytes, err
|
||||
}
|
||||
statusCode := resp.StatusCode
|
||||
//if statusCode != 200 {
|
||||
// return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "bad_response_status_code", statusCode), nullBytes, nil
|
||||
//}
|
||||
err = req.Body.Close()
|
||||
if err != nil {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "close_request_body_failed", statusCode), nullBytes, err
|
||||
}
|
||||
err = c.Request.Body.Close()
|
||||
if err != nil {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "close_request_body_failed", statusCode), nullBytes, err
|
||||
}
|
||||
var midjResponse dto.MidjourneyResponse
|
||||
var midjourneyUploadsResponse dto.MidjourneyUploadResponse
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_response_body_failed", statusCode), nullBytes, err
|
||||
}
|
||||
CloseResponseBodyGracefully(resp)
|
||||
respStr := string(responseBody)
|
||||
log.Printf("respStr: %s", respStr)
|
||||
if respStr == "" {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response_body", statusCode), responseBody, nil
|
||||
} else {
|
||||
err = json.Unmarshal(responseBody, &midjResponse)
|
||||
if err != nil {
|
||||
err2 := json.Unmarshal(responseBody, &midjourneyUploadsResponse)
|
||||
if err2 != nil {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "unmarshal_response_body_failed", statusCode), responseBody, err
|
||||
}
|
||||
}
|
||||
}
|
||||
//log.Printf("midjResponse: %v", midjResponse)
|
||||
//for k, v := range resp.Header {
|
||||
// c.Writer.Header().Set(k, v[0])
|
||||
//}
|
||||
return &dto.MidjourneyResponseWithStatusCode{
|
||||
StatusCode: statusCode,
|
||||
Response: midjResponse,
|
||||
}, responseBody, nil
|
||||
}
|
||||
118
service/notify-limit.go
Normal file
118
service/notify-limit.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
)
|
||||
|
||||
// notifyLimitStore is used for in-memory rate limiting when Redis is disabled
|
||||
var (
|
||||
notifyLimitStore sync.Map
|
||||
cleanupOnce sync.Once
|
||||
)
|
||||
|
||||
type limitCount struct {
|
||||
Count int
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func getDuration() time.Duration {
|
||||
minute := constant.NotificationLimitDurationMinute
|
||||
return time.Duration(minute) * time.Minute
|
||||
}
|
||||
|
||||
// startCleanupTask starts a background task to clean up expired entries
|
||||
func startCleanupTask() {
|
||||
gopool.Go(func() {
|
||||
for {
|
||||
time.Sleep(time.Hour)
|
||||
now := time.Now()
|
||||
notifyLimitStore.Range(func(key, value interface{}) bool {
|
||||
if limit, ok := value.(limitCount); ok {
|
||||
if now.Sub(limit.Timestamp) >= getDuration() {
|
||||
notifyLimitStore.Delete(key)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// CheckNotificationLimit checks if the user has exceeded their notification limit
|
||||
// Returns true if the user can send notification, false if limit exceeded
|
||||
func CheckNotificationLimit(userId int, notifyType string) (bool, error) {
|
||||
if common.RedisEnabled {
|
||||
return checkRedisLimit(userId, notifyType)
|
||||
}
|
||||
return checkMemoryLimit(userId, notifyType)
|
||||
}
|
||||
|
||||
func checkRedisLimit(userId int, notifyType string) (bool, error) {
|
||||
key := fmt.Sprintf("notify_limit:%d:%s:%s", userId, notifyType, time.Now().Format("2006010215"))
|
||||
|
||||
// Get current count
|
||||
count, err := common.RedisGet(key)
|
||||
if err != nil && err.Error() != "redis: nil" {
|
||||
return false, fmt.Errorf("failed to get notification count: %w", err)
|
||||
}
|
||||
|
||||
// If key doesn't exist, initialize it
|
||||
if count == "" {
|
||||
err = common.RedisSet(key, "1", getDuration())
|
||||
return true, err
|
||||
}
|
||||
|
||||
currentCount, _ := strconv.Atoi(count)
|
||||
limit := constant.NotifyLimitCount
|
||||
|
||||
// Check if limit is already reached
|
||||
if currentCount >= limit {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Only increment if under limit
|
||||
err = common.RedisIncr(key, 1)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to increment notification count: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func checkMemoryLimit(userId int, notifyType string) (bool, error) {
|
||||
// Ensure cleanup task is started
|
||||
cleanupOnce.Do(startCleanupTask)
|
||||
|
||||
key := fmt.Sprintf("%d:%s:%s", userId, notifyType, time.Now().Format("2006010215"))
|
||||
now := time.Now()
|
||||
|
||||
// Get current limit count or initialize new one
|
||||
var currentLimit limitCount
|
||||
if value, ok := notifyLimitStore.Load(key); ok {
|
||||
currentLimit = value.(limitCount)
|
||||
// Check if the entry has expired
|
||||
if now.Sub(currentLimit.Timestamp) >= getDuration() {
|
||||
currentLimit = limitCount{Count: 0, Timestamp: now}
|
||||
}
|
||||
} else {
|
||||
currentLimit = limitCount{Count: 0, Timestamp: now}
|
||||
}
|
||||
|
||||
// Increment count
|
||||
currentLimit.Count++
|
||||
|
||||
// Check against limits
|
||||
limit := constant.NotifyLimitCount
|
||||
|
||||
// Store updated count
|
||||
notifyLimitStore.Store(key, currentLimit)
|
||||
|
||||
return currentLimit.Count <= limit, nil
|
||||
}
|
||||
18
service/openai_chat_responses_compat.go
Normal file
18
service/openai_chat_responses_compat.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/service/openaicompat"
|
||||
)
|
||||
|
||||
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
|
||||
return openaicompat.ChatCompletionsRequestToResponsesRequest(req)
|
||||
}
|
||||
|
||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||
return openaicompat.ResponsesResponseToChatCompletionsResponse(resp, id)
|
||||
}
|
||||
|
||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
return openaicompat.ExtractOutputTextFromResponses(resp)
|
||||
}
|
||||
14
service/openai_chat_responses_mode.go
Normal file
14
service/openai_chat_responses_mode.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/service/openaicompat"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
)
|
||||
|
||||
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
|
||||
return openaicompat.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, channelType, model)
|
||||
}
|
||||
|
||||
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
|
||||
return openaicompat.ShouldChatCompletionsUseResponsesGlobal(channelID, channelType, model)
|
||||
}
|
||||
402
service/openaicompat/chat_to_responses.go
Normal file
402
service/openaicompat/chat_to_responses.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package openaicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func normalizeChatImageURLToString(v any) any {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case map[string]any:
|
||||
if url := common.Interface2String(vv["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
return v
|
||||
case dto.MessageImageUrl:
|
||||
if vv.Url != "" {
|
||||
return vv.Url
|
||||
}
|
||||
return v
|
||||
case *dto.MessageImageUrl:
|
||||
if vv != nil && vv.Url != "" {
|
||||
return vv.Url
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) json.RawMessage {
|
||||
if reqFormat == nil || strings.TrimSpace(reqFormat.Type) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
format := map[string]any{
|
||||
"type": reqFormat.Type,
|
||||
}
|
||||
|
||||
if reqFormat.Type == "json_schema" && len(reqFormat.JsonSchema) > 0 {
|
||||
var chatSchema map[string]any
|
||||
if err := common.Unmarshal(reqFormat.JsonSchema, &chatSchema); err == nil {
|
||||
for key, value := range chatSchema {
|
||||
if key == "type" {
|
||||
continue
|
||||
}
|
||||
format[key] = value
|
||||
}
|
||||
|
||||
if nested, ok := format["json_schema"].(map[string]any); ok {
|
||||
for key, value := range nested {
|
||||
if _, exists := format[key]; !exists {
|
||||
format[key] = value
|
||||
}
|
||||
}
|
||||
delete(format, "json_schema")
|
||||
}
|
||||
} else {
|
||||
format["json_schema"] = reqFormat.JsonSchema
|
||||
}
|
||||
}
|
||||
|
||||
textRaw, _ := common.Marshal(map[string]any{
|
||||
"format": format,
|
||||
})
|
||||
return textRaw
|
||||
}
|
||||
|
||||
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
if req.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if lo.FromPtrOr(req.N, 1) > 1 {
|
||||
return nil, fmt.Errorf("n>1 is not supported in responses compatibility mode")
|
||||
}
|
||||
|
||||
var instructionsParts []string
|
||||
inputItems := make([]map[string]any, 0, len(req.Messages))
|
||||
|
||||
for _, msg := range req.Messages {
|
||||
role := strings.TrimSpace(msg.Role)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if role == "tool" || role == "function" {
|
||||
callID := strings.TrimSpace(msg.ToolCallId)
|
||||
|
||||
var output any
|
||||
if msg.Content == nil {
|
||||
output = ""
|
||||
} else if msg.IsStringContent() {
|
||||
output = msg.StringContent()
|
||||
} else {
|
||||
if b, err := common.Marshal(msg.Content); err == nil {
|
||||
output = string(b)
|
||||
} else {
|
||||
output = fmt.Sprintf("%v", msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
if callID == "" {
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"role": "user",
|
||||
"content": fmt.Sprintf("[tool_output_missing_call_id] %v", output),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": callID,
|
||||
"output": output,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefer mapping system/developer messages into `instructions`.
|
||||
if role == "system" || role == "developer" {
|
||||
if msg.Content == nil {
|
||||
continue
|
||||
}
|
||||
if msg.IsStringContent() {
|
||||
if s := strings.TrimSpace(msg.StringContent()); s != "" {
|
||||
instructionsParts = append(instructionsParts, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := msg.ParseContent()
|
||||
var sb strings.Builder
|
||||
for _, part := range parts {
|
||||
if part.Type == dto.ContentTypeText && strings.TrimSpace(part.Text) != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
if s := strings.TrimSpace(sb.String()); s != "" {
|
||||
instructionsParts = append(instructionsParts, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
item := map[string]any{
|
||||
"role": role,
|
||||
}
|
||||
|
||||
if msg.Content == nil {
|
||||
item["content"] = ""
|
||||
inputItems = append(inputItems, item)
|
||||
|
||||
if role == "assistant" {
|
||||
for _, tc := range msg.ParseToolCalls() {
|
||||
if strings.TrimSpace(tc.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Type != "" && tc.Type != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.IsStringContent() {
|
||||
item["content"] = msg.StringContent()
|
||||
inputItems = append(inputItems, item)
|
||||
|
||||
if role == "assistant" {
|
||||
for _, tc := range msg.ParseToolCalls() {
|
||||
if strings.TrimSpace(tc.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Type != "" && tc.Type != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
parts := msg.ParseContent()
|
||||
contentParts := make([]map[string]any, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case dto.ContentTypeText:
|
||||
textType := "input_text"
|
||||
if role == "assistant" {
|
||||
textType = "output_text"
|
||||
}
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": textType,
|
||||
"text": part.Text,
|
||||
})
|
||||
case dto.ContentTypeImageURL:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": normalizeChatImageURLToString(part.ImageUrl),
|
||||
})
|
||||
case dto.ContentTypeInputAudio:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_audio",
|
||||
"input_audio": part.InputAudio,
|
||||
})
|
||||
case dto.ContentTypeFile:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_file",
|
||||
"file": part.File,
|
||||
})
|
||||
case dto.ContentTypeVideoUrl:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_video",
|
||||
"video_url": part.VideoUrl,
|
||||
})
|
||||
default:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": part.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
item["content"] = contentParts
|
||||
inputItems = append(inputItems, item)
|
||||
|
||||
if role == "assistant" {
|
||||
for _, tc := range msg.ParseToolCalls() {
|
||||
if strings.TrimSpace(tc.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Type != "" && tc.Type != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inputRaw, err := common.Marshal(inputItems)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var instructionsRaw json.RawMessage
|
||||
if len(instructionsParts) > 0 {
|
||||
instructions := strings.Join(instructionsParts, "\n\n")
|
||||
instructionsRaw, _ = common.Marshal(instructions)
|
||||
}
|
||||
|
||||
var toolsRaw json.RawMessage
|
||||
if req.Tools != nil {
|
||||
tools := make([]map[string]any, 0, len(req.Tools))
|
||||
for _, tool := range req.Tools {
|
||||
switch tool.Type {
|
||||
case "function":
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"name": tool.Function.Name,
|
||||
"description": tool.Function.Description,
|
||||
"parameters": tool.Function.Parameters,
|
||||
})
|
||||
default:
|
||||
// Best-effort: keep original tool shape for unknown types.
|
||||
var m map[string]any
|
||||
if b, err := common.Marshal(tool); err == nil {
|
||||
_ = common.Unmarshal(b, &m)
|
||||
}
|
||||
if len(m) == 0 {
|
||||
m = map[string]any{"type": tool.Type}
|
||||
}
|
||||
tools = append(tools, m)
|
||||
}
|
||||
}
|
||||
toolsRaw, _ = common.Marshal(tools)
|
||||
}
|
||||
|
||||
var toolChoiceRaw json.RawMessage
|
||||
if req.ToolChoice != nil {
|
||||
switch v := req.ToolChoice.(type) {
|
||||
case string:
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
default:
|
||||
var m map[string]any
|
||||
if b, err := common.Marshal(v); err == nil {
|
||||
_ = common.Unmarshal(b, &m)
|
||||
}
|
||||
if m == nil {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
} else if t, _ := m["type"].(string); t == "function" {
|
||||
// Chat: {"type":"function","function":{"name":"..."}}
|
||||
// Responses: {"type":"function","name":"..."}
|
||||
if name, ok := m["name"].(string); ok && name != "" {
|
||||
toolChoiceRaw, _ = common.Marshal(map[string]any{
|
||||
"type": "function",
|
||||
"name": name,
|
||||
})
|
||||
} else if fn, ok := m["function"].(map[string]any); ok {
|
||||
if name, ok := fn["name"].(string); ok && name != "" {
|
||||
toolChoiceRaw, _ = common.Marshal(map[string]any{
|
||||
"type": "function",
|
||||
"name": name,
|
||||
})
|
||||
} else {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
}
|
||||
} else {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
}
|
||||
} else {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parallelToolCallsRaw json.RawMessage
|
||||
if req.ParallelTooCalls != nil {
|
||||
parallelToolCallsRaw, _ = common.Marshal(*req.ParallelTooCalls)
|
||||
}
|
||||
|
||||
textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat)
|
||||
|
||||
maxOutputTokens := lo.FromPtrOr(req.MaxTokens, uint(0))
|
||||
maxCompletionTokens := lo.FromPtrOr(req.MaxCompletionTokens, uint(0))
|
||||
if maxCompletionTokens > maxOutputTokens {
|
||||
maxOutputTokens = maxCompletionTokens
|
||||
}
|
||||
// OpenAI Responses API rejects max_output_tokens < 16 when explicitly provided.
|
||||
//if maxOutputTokens > 0 && maxOutputTokens < 16 {
|
||||
// maxOutputTokens = 16
|
||||
//}
|
||||
|
||||
var topP *float64
|
||||
if req.TopP != nil {
|
||||
topP = common.GetPointer(lo.FromPtr(req.TopP))
|
||||
}
|
||||
|
||||
out := &dto.OpenAIResponsesRequest{
|
||||
Model: req.Model,
|
||||
Input: inputRaw,
|
||||
Instructions: instructionsRaw,
|
||||
Stream: req.Stream,
|
||||
Temperature: req.Temperature,
|
||||
Text: textRaw,
|
||||
ToolChoice: toolChoiceRaw,
|
||||
Tools: toolsRaw,
|
||||
TopP: topP,
|
||||
User: req.User,
|
||||
ParallelToolCalls: parallelToolCallsRaw,
|
||||
Store: req.Store,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
if req.MaxTokens != nil || req.MaxCompletionTokens != nil {
|
||||
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
|
||||
}
|
||||
|
||||
if req.ReasoningEffort != "" {
|
||||
out.Reasoning = &dto.Reasoning{
|
||||
Effort: req.ReasoningEffort,
|
||||
Summary: "detailed",
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
19
service/openaicompat/policy.go
Normal file
19
service/openaicompat/policy.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package openaicompat
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/model_setting"
|
||||
|
||||
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
|
||||
if !policy.IsChannelEnabled(channelID, channelType) {
|
||||
return false
|
||||
}
|
||||
return matchAnyRegex(policy.ModelPatterns, model)
|
||||
}
|
||||
|
||||
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
|
||||
return ShouldChatCompletionsUseResponsesPolicy(
|
||||
model_setting.GetGlobalSettings().ChatCompletionsToResponsesPolicy,
|
||||
channelID,
|
||||
channelType,
|
||||
model,
|
||||
)
|
||||
}
|
||||
33
service/openaicompat/regex.go
Normal file
33
service/openaicompat/regex.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package openaicompat
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var compiledRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
|
||||
func matchAnyRegex(patterns []string, s string) bool {
|
||||
if len(patterns) == 0 || s == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
re, ok := compiledRegexCache.Load(pattern)
|
||||
if !ok {
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
// Treat invalid patterns as non-matching to avoid breaking runtime traffic.
|
||||
continue
|
||||
}
|
||||
re = compiled
|
||||
compiledRegexCache.Store(pattern, re)
|
||||
}
|
||||
if re.(*regexp.Regexp).MatchString(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
133
service/openaicompat/responses_to_chat.go
Normal file
133
service/openaicompat/responses_to_chat.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package openaicompat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
|
||||
text := ExtractOutputTextFromResponses(resp)
|
||||
|
||||
usage := &dto.Usage{}
|
||||
if resp.Usage != nil {
|
||||
if resp.Usage.InputTokens != 0 {
|
||||
usage.PromptTokens = resp.Usage.InputTokens
|
||||
usage.InputTokens = resp.Usage.InputTokens
|
||||
}
|
||||
if resp.Usage.OutputTokens != 0 {
|
||||
usage.CompletionTokens = resp.Usage.OutputTokens
|
||||
usage.OutputTokens = resp.Usage.OutputTokens
|
||||
}
|
||||
if resp.Usage.TotalTokens != 0 {
|
||||
usage.TotalTokens = resp.Usage.TotalTokens
|
||||
} else {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
if resp.Usage.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = resp.Usage.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.ImageTokens = resp.Usage.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.AudioTokens = resp.Usage.InputTokensDetails.AudioTokens
|
||||
}
|
||||
if resp.Usage.CompletionTokenDetails.ReasoningTokens != 0 {
|
||||
usage.CompletionTokenDetails.ReasoningTokens = resp.Usage.CompletionTokenDetails.ReasoningTokens
|
||||
}
|
||||
}
|
||||
|
||||
created := resp.CreatedAt
|
||||
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
if text == "" && len(resp.Output) > 0 {
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != "function_call" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(out.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
callId := strings.TrimSpace(out.CallId)
|
||||
if callId == "" {
|
||||
callId = strings.TrimSpace(out.ID)
|
||||
}
|
||||
toolCalls = append(toolCalls, dto.ToolCallResponse{
|
||||
ID: callId,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Name: name,
|
||||
Arguments: out.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
|
||||
msg := dto.Message{
|
||||
Role: "assistant",
|
||||
Content: text,
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
msg.SetToolCalls(toolCalls)
|
||||
msg.Content = ""
|
||||
}
|
||||
|
||||
out := &dto.OpenAITextResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Model: resp.Model,
|
||||
Choices: []dto.OpenAITextResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
Usage: *usage,
|
||||
}
|
||||
|
||||
return out, usage, nil
|
||||
}
|
||||
|
||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Prefer assistant message outputs.
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != "message" {
|
||||
continue
|
||||
}
|
||||
if out.Role != "" && out.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "output_text" && c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
return sb.String()
|
||||
}
|
||||
for _, out := range resp.Output {
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
177
service/passkey/service.go
Normal file
177
service/passkey/service.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package passkey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
webauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
const (
|
||||
RegistrationSessionKey = "passkey_registration_session"
|
||||
LoginSessionKey = "passkey_login_session"
|
||||
VerifySessionKey = "passkey_verify_session"
|
||||
)
|
||||
|
||||
// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context.
|
||||
func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
if settings == nil {
|
||||
return nil, errors.New("未找到 Passkey 设置")
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(settings.RPDisplayName)
|
||||
if displayName == "" {
|
||||
displayName = common.SystemName
|
||||
}
|
||||
|
||||
origins, err := resolveOrigins(r, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rpID, err := resolveRPID(r, settings, origins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selection := protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||
RequireResidentKey: protocol.ResidentKeyRequired(),
|
||||
UserVerification: protocol.UserVerificationRequirement(settings.UserVerification),
|
||||
}
|
||||
if selection.UserVerification == "" {
|
||||
selection.UserVerification = protocol.VerificationPreferred
|
||||
}
|
||||
if attachment := strings.TrimSpace(settings.AttachmentPreference); attachment != "" {
|
||||
selection.AuthenticatorAttachment = protocol.AuthenticatorAttachment(attachment)
|
||||
}
|
||||
|
||||
config := &webauthn.Config{
|
||||
RPID: rpID,
|
||||
RPDisplayName: displayName,
|
||||
RPOrigins: origins,
|
||||
AuthenticatorSelection: selection,
|
||||
Debug: common.DebugEnabled,
|
||||
Timeouts: webauthn.TimeoutsConfig{
|
||||
Login: webauthn.TimeoutConfig{
|
||||
Enforce: true,
|
||||
Timeout: 2 * time.Minute,
|
||||
TimeoutUVD: 2 * time.Minute,
|
||||
},
|
||||
Registration: webauthn.TimeoutConfig{
|
||||
Enforce: true,
|
||||
Timeout: 2 * time.Minute,
|
||||
TimeoutUVD: 2 * time.Minute,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return webauthn.New(config)
|
||||
}
|
||||
|
||||
func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) {
|
||||
originsStr := strings.TrimSpace(settings.Origins)
|
||||
if originsStr != "" {
|
||||
originList := strings.Split(originsStr, ",")
|
||||
origins := make([]string, 0, len(originList))
|
||||
for _, origin := range originList {
|
||||
trimmed := strings.TrimSpace(origin)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if !settings.AllowInsecureOrigin && strings.HasPrefix(strings.ToLower(trimmed), "http://") {
|
||||
return nil, fmt.Errorf("Passkey 不允许使用不安全的 Origin: %s", trimmed)
|
||||
}
|
||||
origins = append(origins, trimmed)
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
// 如果配置了Origins但过滤后为空,使用自动推导
|
||||
goto autoDetect
|
||||
}
|
||||
return origins, nil
|
||||
}
|
||||
|
||||
autoDetect:
|
||||
scheme := detectScheme(r)
|
||||
if scheme == "http" && !settings.AllowInsecureOrigin && r.Host != "localhost" && r.Host != "127.0.0.1" && !strings.HasPrefix(r.Host, "127.0.0.1:") && !strings.HasPrefix(r.Host, "localhost:") {
|
||||
return nil, fmt.Errorf("Passkey 仅支持 HTTPS,当前访问: %s://%s,请在 Passkey 设置中允许不安全 Origin 或配置 HTTPS", scheme, r.Host)
|
||||
}
|
||||
// 优先使用请求的完整Host(包含端口)
|
||||
host := r.Host
|
||||
|
||||
// 如果无法从请求获取Host,尝试从ServerAddress获取
|
||||
if host == "" && system_setting.ServerAddress != "" {
|
||||
if parsed, err := url.Parse(system_setting.ServerAddress); err == nil && parsed.Host != "" {
|
||||
host = parsed.Host
|
||||
if scheme == "" && parsed.Scheme != "" {
|
||||
scheme = parsed.Scheme
|
||||
}
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("无法确定 Passkey 的 Origin,请在系统设置或 Passkey 设置中指定。当前 Host: '%s', ServerAddress: '%s'", r.Host, system_setting.ServerAddress)
|
||||
}
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
origin := fmt.Sprintf("%s://%s", scheme, host)
|
||||
return []string{origin}, nil
|
||||
}
|
||||
|
||||
func resolveRPID(r *http.Request, settings *system_setting.PasskeySettings, origins []string) (string, error) {
|
||||
rpID := strings.TrimSpace(settings.RPID)
|
||||
if rpID != "" {
|
||||
return hostWithoutPort(rpID), nil
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
return "", errors.New("Passkey 未配置 Origin,无法推导 RPID")
|
||||
}
|
||||
parsed, err := url.Parse(origins[0])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("无法解析 Passkey Origin: %w", err)
|
||||
}
|
||||
return hostWithoutPort(parsed.Host), nil
|
||||
}
|
||||
|
||||
func hostWithoutPort(host string) string {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(host, ":") {
|
||||
if host, _, err := net.SplitHostPort(host); err == nil {
|
||||
return host
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func detectScheme(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
parts := strings.Split(proto, ",")
|
||||
return strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if r.URL != nil && r.URL.Scheme != "" {
|
||||
return strings.ToLower(r.URL.Scheme)
|
||||
}
|
||||
if r.Header.Get("X-Forwarded-Protocol") != "" {
|
||||
return strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Protocol")))
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
50
service/passkey/session.go
Normal file
50
service/passkey/session.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package passkey
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
webauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
var errSessionNotFound = errors.New("Passkey 会话不存在或已过期")
|
||||
|
||||
func SaveSessionData(c *gin.Context, key string, data *webauthn.SessionData) error {
|
||||
session := sessions.Default(c)
|
||||
if data == nil {
|
||||
session.Delete(key)
|
||||
return session.Save()
|
||||
}
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.Set(key, string(payload))
|
||||
return session.Save()
|
||||
}
|
||||
|
||||
func PopSessionData(c *gin.Context, key string) (*webauthn.SessionData, error) {
|
||||
session := sessions.Default(c)
|
||||
raw := session.Get(key)
|
||||
if raw == nil {
|
||||
return nil, errSessionNotFound
|
||||
}
|
||||
session.Delete(key)
|
||||
_ = session.Save()
|
||||
var data webauthn.SessionData
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
if err := json.Unmarshal([]byte(value), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case []byte:
|
||||
if err := json.Unmarshal(value, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("Passkey 会话格式无效")
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
71
service/passkey/user.go
Normal file
71
service/passkey/user.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package passkey
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
webauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
type WebAuthnUser struct {
|
||||
user *model.User
|
||||
credential *model.PasskeyCredential
|
||||
}
|
||||
|
||||
func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser {
|
||||
return &WebAuthnUser{user: user, credential: credential}
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnID() []byte {
|
||||
if u == nil || u.user == nil {
|
||||
return nil
|
||||
}
|
||||
return []byte(strconv.Itoa(u.user.Id))
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnName() string {
|
||||
if u == nil || u.user == nil {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimSpace(u.user.Username)
|
||||
if name == "" {
|
||||
return fmt.Sprintf("user-%d", u.user.Id)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnDisplayName() string {
|
||||
if u == nil || u.user == nil {
|
||||
return ""
|
||||
}
|
||||
display := strings.TrimSpace(u.user.DisplayName)
|
||||
if display != "" {
|
||||
return display
|
||||
}
|
||||
return u.WebAuthnName()
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
if u == nil || u.credential == nil {
|
||||
return nil
|
||||
}
|
||||
cred := u.credential.ToWebAuthnCredential()
|
||||
return []webauthn.Credential{cred}
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) ModelUser() *model.User {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return u.user
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return u.credential
|
||||
}
|
||||
507
service/quota.go
Normal file
507
service/quota.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type TokenDetails struct {
|
||||
TextTokens int
|
||||
AudioTokens int
|
||||
}
|
||||
|
||||
type QuotaInfo struct {
|
||||
InputDetails TokenDetails
|
||||
OutputDetails TokenDetails
|
||||
ModelName string
|
||||
UsePrice bool
|
||||
ModelPrice float64
|
||||
ModelRatio float64
|
||||
GroupRatio float64
|
||||
}
|
||||
|
||||
func hasCustomModelRatio(modelName string, currentRatio float64) bool {
|
||||
defaultRatio, exists := ratio_setting.GetDefaultModelRatioMap()[modelName]
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
return currentRatio != defaultRatio
|
||||
}
|
||||
|
||||
func calculateAudioQuota(info QuotaInfo) int {
|
||||
if info.UsePrice {
|
||||
modelPrice := decimal.NewFromFloat(info.ModelPrice)
|
||||
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
groupRatio := decimal.NewFromFloat(info.GroupRatio)
|
||||
|
||||
quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio)
|
||||
return int(quota.IntPart())
|
||||
}
|
||||
|
||||
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName))
|
||||
audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(info.ModelName))
|
||||
audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(info.ModelName))
|
||||
|
||||
groupRatio := decimal.NewFromFloat(info.GroupRatio)
|
||||
modelRatio := decimal.NewFromFloat(info.ModelRatio)
|
||||
ratio := groupRatio.Mul(modelRatio)
|
||||
|
||||
inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens))
|
||||
outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens))
|
||||
inputAudioTokens := decimal.NewFromInt(int64(info.InputDetails.AudioTokens))
|
||||
outputAudioTokens := decimal.NewFromInt(int64(info.OutputDetails.AudioTokens))
|
||||
|
||||
quota := decimal.Zero
|
||||
quota = quota.Add(inputTextTokens)
|
||||
quota = quota.Add(outputTextTokens.Mul(completionRatio))
|
||||
quota = quota.Add(inputAudioTokens.Mul(audioRatio))
|
||||
quota = quota.Add(outputAudioTokens.Mul(audioRatio).Mul(audioCompletionRatio))
|
||||
|
||||
quota = quota.Mul(ratio)
|
||||
|
||||
// If ratio is not zero and quota is less than or equal to zero, set quota to 1
|
||||
if !ratio.IsZero() && quota.LessThanOrEqual(decimal.Zero) {
|
||||
quota = decimal.NewFromInt(1)
|
||||
}
|
||||
|
||||
return int(quota.Round(0).IntPart())
|
||||
}
|
||||
|
||||
func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage) error {
|
||||
if relayInfo.UsePrice {
|
||||
return nil
|
||||
}
|
||||
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := model.GetTokenByKey(strings.TrimPrefix(relayInfo.TokenKey, "sk-"), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
modelName := relayInfo.OriginModelName
|
||||
textInputTokens := usage.InputTokenDetails.TextTokens
|
||||
textOutTokens := usage.OutputTokenDetails.TextTokens
|
||||
audioInputTokens := usage.InputTokenDetails.AudioTokens
|
||||
audioOutTokens := usage.OutputTokenDetails.AudioTokens
|
||||
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
|
||||
modelRatio, _, _ := ratio_setting.GetModelRatio(modelName)
|
||||
|
||||
autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup)
|
||||
if exists {
|
||||
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
|
||||
log.Printf("final group ratio: %f", groupRatio)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
actualGroupRatio := groupRatio
|
||||
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
|
||||
if ok {
|
||||
actualGroupRatio = userGroupRatio
|
||||
}
|
||||
|
||||
quotaInfo := QuotaInfo{
|
||||
InputDetails: TokenDetails{
|
||||
TextTokens: textInputTokens,
|
||||
AudioTokens: audioInputTokens,
|
||||
},
|
||||
OutputDetails: TokenDetails{
|
||||
TextTokens: textOutTokens,
|
||||
AudioTokens: audioOutTokens,
|
||||
},
|
||||
ModelName: modelName,
|
||||
UsePrice: relayInfo.UsePrice,
|
||||
ModelRatio: modelRatio,
|
||||
GroupRatio: actualGroupRatio,
|
||||
}
|
||||
|
||||
quota := calculateAudioQuota(quotaInfo)
|
||||
|
||||
if userQuota < quota {
|
||||
return fmt.Errorf("user quota is not enough, user quota: %s, need quota: %s", logger.FormatQuota(userQuota), logger.FormatQuota(quota))
|
||||
}
|
||||
|
||||
if !token.UnlimitedQuota && token.RemainQuota < quota {
|
||||
return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(token.RemainQuota), logger.FormatQuota(quota))
|
||||
}
|
||||
|
||||
err = PostConsumeQuota(relayInfo, quota, 0, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.LogInfo(ctx, "realtime streaming consume quota success, quota: "+fmt.Sprintf("%d", quota))
|
||||
return nil
|
||||
}
|
||||
|
||||
func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
|
||||
usage *dto.RealtimeUsage, extraContent string) {
|
||||
|
||||
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
||||
textInputTokens := usage.InputTokenDetails.TextTokens
|
||||
textOutTokens := usage.OutputTokenDetails.TextTokens
|
||||
|
||||
audioInputTokens := usage.InputTokenDetails.AudioTokens
|
||||
audioOutTokens := usage.OutputTokenDetails.AudioTokens
|
||||
|
||||
tokenName := ctx.GetString("token_name")
|
||||
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(modelName))
|
||||
audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName))
|
||||
audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(modelName))
|
||||
|
||||
modelRatio := relayInfo.PriceData.ModelRatio
|
||||
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
|
||||
modelPrice := relayInfo.PriceData.ModelPrice
|
||||
usePrice := relayInfo.PriceData.UsePrice
|
||||
|
||||
quotaInfo := QuotaInfo{
|
||||
InputDetails: TokenDetails{
|
||||
TextTokens: textInputTokens,
|
||||
AudioTokens: audioInputTokens,
|
||||
},
|
||||
OutputDetails: TokenDetails{
|
||||
TextTokens: textOutTokens,
|
||||
AudioTokens: audioOutTokens,
|
||||
},
|
||||
ModelName: modelName,
|
||||
UsePrice: usePrice,
|
||||
ModelRatio: modelRatio,
|
||||
GroupRatio: groupRatio,
|
||||
}
|
||||
|
||||
quota := calculateAudioQuota(quotaInfo)
|
||||
|
||||
totalTokens := usage.TotalTokens
|
||||
var logContent string
|
||||
if !usePrice {
|
||||
logContent = fmt.Sprintf("模型倍率 %.2f,补全倍率 %.2f,音频倍率 %.2f,音频补全倍率 %.2f,分组倍率 %.2f",
|
||||
modelRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), groupRatio)
|
||||
} else {
|
||||
logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio)
|
||||
}
|
||||
|
||||
// record all the consume log even if quota is 0
|
||||
if totalTokens == 0 {
|
||||
// in this case, must be some error happened
|
||||
// we cannot just return, because we may have to return the pre-consumed quota
|
||||
quota = 0
|
||||
logContent += fmt.Sprintf("(可能是上游超时)")
|
||||
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
|
||||
"tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
|
||||
} else {
|
||||
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
|
||||
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
|
||||
}
|
||||
|
||||
logModel := modelName
|
||||
if extraContent != "" {
|
||||
logContent += ", " + extraContent
|
||||
}
|
||||
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
||||
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||
ChannelId: relayInfo.ChannelId,
|
||||
PromptTokens: usage.InputTokens,
|
||||
CompletionTokens: usage.OutputTokens,
|
||||
ModelName: logModel,
|
||||
TokenName: tokenName,
|
||||
Quota: quota,
|
||||
Content: logContent,
|
||||
TokenId: relayInfo.TokenId,
|
||||
UseTimeSeconds: int(useTimeSeconds),
|
||||
IsStream: relayInfo.IsStream,
|
||||
Group: relayInfo.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
}
|
||||
|
||||
func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData) int {
|
||||
if priceData.CacheCreationRatio == 1 {
|
||||
return 0
|
||||
}
|
||||
quotaPrice := priceData.ModelRatio / common.QuotaPerUnit
|
||||
promptCacheCreatePrice := quotaPrice * priceData.CacheCreationRatio
|
||||
promptCacheReadPrice := quotaPrice * priceData.CacheRatio
|
||||
completionPrice := quotaPrice * priceData.CompletionRatio
|
||||
|
||||
cost, _ := usage.Cost.(float64)
|
||||
totalPromptTokens := float64(usage.PromptTokens)
|
||||
completionTokens := float64(usage.CompletionTokens)
|
||||
promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens)
|
||||
|
||||
return int(math.Round((cost -
|
||||
totalPromptTokens*quotaPrice +
|
||||
promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) -
|
||||
completionTokens*completionPrice) /
|
||||
(promptCacheCreatePrice - quotaPrice)))
|
||||
}
|
||||
|
||||
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) {
|
||||
|
||||
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
||||
textInputTokens := usage.PromptTokensDetails.TextTokens
|
||||
textOutTokens := usage.CompletionTokenDetails.TextTokens
|
||||
|
||||
audioInputTokens := usage.PromptTokensDetails.AudioTokens
|
||||
audioOutTokens := usage.CompletionTokenDetails.AudioTokens
|
||||
|
||||
tokenName := ctx.GetString("token_name")
|
||||
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(relayInfo.OriginModelName))
|
||||
audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName))
|
||||
audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(relayInfo.OriginModelName))
|
||||
|
||||
modelRatio := relayInfo.PriceData.ModelRatio
|
||||
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
|
||||
modelPrice := relayInfo.PriceData.ModelPrice
|
||||
usePrice := relayInfo.PriceData.UsePrice
|
||||
|
||||
quotaInfo := QuotaInfo{
|
||||
InputDetails: TokenDetails{
|
||||
TextTokens: textInputTokens,
|
||||
AudioTokens: audioInputTokens,
|
||||
},
|
||||
OutputDetails: TokenDetails{
|
||||
TextTokens: textOutTokens,
|
||||
AudioTokens: audioOutTokens,
|
||||
},
|
||||
ModelName: relayInfo.OriginModelName,
|
||||
UsePrice: usePrice,
|
||||
ModelRatio: modelRatio,
|
||||
GroupRatio: groupRatio,
|
||||
}
|
||||
|
||||
quota := calculateAudioQuota(quotaInfo)
|
||||
|
||||
totalTokens := usage.TotalTokens
|
||||
var logContent string
|
||||
if !usePrice {
|
||||
logContent = fmt.Sprintf("模型倍率 %.2f,补全倍率 %.2f,音频倍率 %.2f,音频补全倍率 %.2f,分组倍率 %.2f",
|
||||
modelRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), groupRatio)
|
||||
} else {
|
||||
logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio)
|
||||
}
|
||||
|
||||
// record all the consume log even if quota is 0
|
||||
if totalTokens == 0 {
|
||||
// in this case, must be some error happened
|
||||
// we cannot just return, because we may have to return the pre-consumed quota
|
||||
quota = 0
|
||||
logContent += fmt.Sprintf("(可能是上游超时)")
|
||||
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
|
||||
"tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, relayInfo.OriginModelName, relayInfo.FinalPreConsumedQuota))
|
||||
} else {
|
||||
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
|
||||
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
|
||||
}
|
||||
|
||||
if err := SettleBilling(ctx, relayInfo, quota); err != nil {
|
||||
logger.LogError(ctx, "error settling billing: "+err.Error())
|
||||
}
|
||||
|
||||
logModel := relayInfo.OriginModelName
|
||||
if extraContent != "" {
|
||||
logContent += ", " + extraContent
|
||||
}
|
||||
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
||||
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||
ChannelId: relayInfo.ChannelId,
|
||||
PromptTokens: usage.PromptTokens,
|
||||
CompletionTokens: usage.CompletionTokens,
|
||||
ModelName: logModel,
|
||||
TokenName: tokenName,
|
||||
Quota: quota,
|
||||
Content: logContent,
|
||||
TokenId: relayInfo.TokenId,
|
||||
UseTimeSeconds: int(useTimeSeconds),
|
||||
IsStream: relayInfo.IsStream,
|
||||
Group: relayInfo.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
}
|
||||
|
||||
func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {
|
||||
if quota < 0 {
|
||||
return errors.New("quota 不能为负数!")
|
||||
}
|
||||
if relayInfo.IsPlayground {
|
||||
return nil
|
||||
}
|
||||
//if relayInfo.TokenUnlimited {
|
||||
// return nil
|
||||
//}
|
||||
token, err := model.GetTokenByKey(relayInfo.TokenKey, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !relayInfo.TokenUnlimited && token.RemainQuota < quota {
|
||||
return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(token.RemainQuota), logger.FormatQuota(quota))
|
||||
}
|
||||
err = model.DecreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (err error) {
|
||||
|
||||
// 1) Consume from wallet quota OR subscription item
|
||||
if relayInfo != nil && relayInfo.BillingSource == BillingSourceSubscription {
|
||||
if relayInfo.SubscriptionId == 0 {
|
||||
return errors.New("subscription id is missing")
|
||||
}
|
||||
delta := int64(quota)
|
||||
if delta != 0 {
|
||||
if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
relayInfo.SubscriptionPostDelta += delta
|
||||
}
|
||||
} else {
|
||||
// Wallet
|
||||
if quota > 0 {
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
|
||||
} else {
|
||||
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !relayInfo.IsPlayground {
|
||||
if quota > 0 {
|
||||
err = model.DecreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota)
|
||||
} else {
|
||||
err = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, -quota)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if sendEmail {
|
||||
if (quota + preConsumedQuota) != 0 {
|
||||
checkAndSendQuotaNotify(relayInfo, quota, preConsumedQuota)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int) {
|
||||
gopool.Go(func() {
|
||||
userSetting := relayInfo.UserSetting
|
||||
threshold := common.QuotaRemindThreshold
|
||||
if userSetting.QuotaWarningThreshold != 0 {
|
||||
threshold = int(userSetting.QuotaWarningThreshold)
|
||||
}
|
||||
|
||||
//noMoreQuota := userCache.Quota-(quota+preConsumedQuota) <= 0
|
||||
quotaTooLow := false
|
||||
consumeQuota := quota + preConsumedQuota
|
||||
if relayInfo.UserQuota-consumeQuota < threshold {
|
||||
quotaTooLow = true
|
||||
}
|
||||
if quotaTooLow {
|
||||
prompt := "您的额度即将用尽"
|
||||
topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress)
|
||||
|
||||
// 根据通知方式生成不同的内容格式
|
||||
var content string
|
||||
var values []interface{}
|
||||
|
||||
notifyType := userSetting.NotifyType
|
||||
if notifyType == "" {
|
||||
notifyType = dto.NotifyTypeEmail
|
||||
}
|
||||
|
||||
if notifyType == dto.NotifyTypeBark {
|
||||
// Bark推送使用简短文本,不支持HTML
|
||||
content = "{{value}},剩余额度:{{value}},请及时充值"
|
||||
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
|
||||
} else if notifyType == dto.NotifyTypeGotify {
|
||||
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
|
||||
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
|
||||
} else {
|
||||
// 默认内容格式,适用于Email和Webhook(支持HTML)
|
||||
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
|
||||
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
|
||||
}
|
||||
|
||||
err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values))
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
|
||||
gopool.Go(func() {
|
||||
if relayInfo == nil {
|
||||
return
|
||||
}
|
||||
if relayInfo.SubscriptionId == 0 || relayInfo.SubscriptionAmountTotal <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
userSetting := relayInfo.UserSetting
|
||||
threshold := common.QuotaRemindThreshold
|
||||
if userSetting.QuotaWarningThreshold != 0 {
|
||||
threshold = int(userSetting.QuotaWarningThreshold)
|
||||
}
|
||||
|
||||
usedAfter := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta
|
||||
remaining := relayInfo.SubscriptionAmountTotal - usedAfter
|
||||
if remaining >= int64(threshold) {
|
||||
return
|
||||
}
|
||||
|
||||
prompt := "您的订阅额度即将用尽"
|
||||
topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress)
|
||||
|
||||
var content string
|
||||
var values []interface{}
|
||||
notifyType := userSetting.NotifyType
|
||||
if notifyType == "" {
|
||||
notifyType = dto.NotifyTypeEmail
|
||||
}
|
||||
|
||||
if notifyType == dto.NotifyTypeBark {
|
||||
content = "{{value}},剩余额度:{{value}},请及时充值"
|
||||
values = []interface{}{prompt, logger.FormatQuota(int(remaining))}
|
||||
} else if notifyType == dto.NotifyTypeGotify {
|
||||
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
|
||||
values = []interface{}{prompt, logger.FormatQuota(int(remaining))}
|
||||
} else {
|
||||
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
|
||||
values = []interface{}{prompt, logger.FormatQuota(int(remaining)), topUpLink, topUpLink}
|
||||
}
|
||||
|
||||
if err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values)); err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to send subscription quota notify to user %d: %s", relayInfo.UserId, err.Error()))
|
||||
}
|
||||
})
|
||||
}
|
||||
77
service/sensitive.go
Normal file
77
service/sensitive.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
)
|
||||
|
||||
func CheckSensitiveMessages(messages []dto.Message) ([]string, error) {
|
||||
if len(messages) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for _, message := range messages {
|
||||
arrayContent := message.ParseContent()
|
||||
for _, m := range arrayContent {
|
||||
if m.Type == "image_url" {
|
||||
// TODO: check image url
|
||||
continue
|
||||
}
|
||||
// 检查 text 是否为空
|
||||
if m.Text == "" {
|
||||
continue
|
||||
}
|
||||
if ok, words := SensitiveWordContains(m.Text); ok {
|
||||
return words, errors.New("sensitive words detected")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func CheckSensitiveText(text string) (bool, []string) {
|
||||
return SensitiveWordContains(text)
|
||||
}
|
||||
|
||||
// SensitiveWordContains 是否包含敏感词,返回是否包含敏感词和敏感词列表
|
||||
func SensitiveWordContains(text string) (bool, []string) {
|
||||
if len(setting.SensitiveWords) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if len(text) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
checkText := strings.ToLower(text)
|
||||
return AcSearch(checkText, setting.SensitiveWords, true)
|
||||
}
|
||||
|
||||
// SensitiveWordReplace 敏感词替换,返回是否包含敏感词和替换后的文本
|
||||
func SensitiveWordReplace(text string, returnImmediately bool) (bool, []string, string) {
|
||||
if len(setting.SensitiveWords) == 0 {
|
||||
return false, nil, text
|
||||
}
|
||||
checkText := strings.ToLower(text)
|
||||
m := getOrBuildAC(setting.SensitiveWords)
|
||||
hits := m.MultiPatternSearch([]rune(checkText), returnImmediately)
|
||||
if len(hits) > 0 {
|
||||
words := make([]string, 0, len(hits))
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(text))
|
||||
lastPos := 0
|
||||
|
||||
for _, hit := range hits {
|
||||
pos := hit.Pos
|
||||
word := string(hit.Word)
|
||||
builder.WriteString(text[lastPos:pos])
|
||||
builder.WriteString("**###**")
|
||||
lastPos = pos + len(word)
|
||||
words = append(words, word)
|
||||
}
|
||||
builder.WriteString(text[lastPos:])
|
||||
return true, words, builder.String()
|
||||
}
|
||||
return false, nil, text
|
||||
}
|
||||
152
service/str.go
Normal file
152
service/str.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
goahocorasick "github.com/anknown/ahocorasick"
|
||||
)
|
||||
|
||||
func SundaySearch(text string, pattern string) bool {
|
||||
// 计算偏移表
|
||||
offset := make(map[rune]int)
|
||||
for i, c := range pattern {
|
||||
offset[c] = len(pattern) - i
|
||||
}
|
||||
|
||||
// 文本串长度和模式串长度
|
||||
n, m := len(text), len(pattern)
|
||||
|
||||
// 主循环,i表示当前对齐的文本串位置
|
||||
for i := 0; i <= n-m; {
|
||||
// 检查子串
|
||||
j := 0
|
||||
for j < m && text[i+j] == pattern[j] {
|
||||
j++
|
||||
}
|
||||
// 如果完全匹配,返回匹配位置
|
||||
if j == m {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果还有剩余字符,则检查下一位字符在偏移表中的值
|
||||
if i+m < n {
|
||||
next := rune(text[i+m])
|
||||
if val, ok := offset[next]; ok {
|
||||
i += val // 存在于偏移表中,进行跳跃
|
||||
} else {
|
||||
i += len(pattern) + 1 // 不存在于偏移表中,跳过整个模式串长度
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return false // 如果没有找到匹配,返回-1
|
||||
}
|
||||
|
||||
func RemoveDuplicate(s []string) []string {
|
||||
result := make([]string, 0, len(s))
|
||||
temp := map[string]struct{}{}
|
||||
for _, item := range s {
|
||||
if _, ok := temp[item]; !ok {
|
||||
temp[item] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func InitAc(dict []string) *goahocorasick.Machine {
|
||||
m := new(goahocorasick.Machine)
|
||||
runes := readRunes(dict)
|
||||
if err := m.Build(runes); err != nil {
|
||||
fmt.Println(err)
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
var acCache sync.Map
|
||||
|
||||
func acKey(dict []string) string {
|
||||
if len(dict) == 0 {
|
||||
return ""
|
||||
}
|
||||
normalized := make([]string, 0, len(dict))
|
||||
for _, w := range dict {
|
||||
w = strings.ToLower(strings.TrimSpace(w))
|
||||
if w != "" {
|
||||
normalized = append(normalized, w)
|
||||
}
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(normalized)
|
||||
hasher := fnv.New64a()
|
||||
for _, w := range normalized {
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write([]byte(w))
|
||||
}
|
||||
return fmt.Sprintf("%x", hasher.Sum64())
|
||||
}
|
||||
|
||||
func getOrBuildAC(dict []string) *goahocorasick.Machine {
|
||||
key := acKey(dict)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
if v, ok := acCache.Load(key); ok {
|
||||
if m, ok2 := v.(*goahocorasick.Machine); ok2 {
|
||||
return m
|
||||
}
|
||||
}
|
||||
m := InitAc(dict)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if actual, loaded := acCache.LoadOrStore(key, m); loaded {
|
||||
if cached, ok := actual.(*goahocorasick.Machine); ok {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func readRunes(dict []string) [][]rune {
|
||||
var runes [][]rune
|
||||
|
||||
for _, word := range dict {
|
||||
word = strings.ToLower(word)
|
||||
l := bytes.TrimSpace([]byte(word))
|
||||
runes = append(runes, bytes.Runes(l))
|
||||
}
|
||||
|
||||
return runes
|
||||
}
|
||||
|
||||
func AcSearch(findText string, dict []string, stopImmediately bool) (bool, []string) {
|
||||
if len(dict) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if len(findText) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
m := getOrBuildAC(dict)
|
||||
if m == nil {
|
||||
return false, nil
|
||||
}
|
||||
hits := m.MultiPatternSearch([]rune(findText), stopImmediately)
|
||||
if len(hits) > 0 {
|
||||
words := make([]string, 0)
|
||||
for _, hit := range hits {
|
||||
words = append(words, string(hit.Word))
|
||||
}
|
||||
return true, words
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
93
service/subscription_reset_task.go
Normal file
93
service/subscription_reset_task.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
)
|
||||
|
||||
const (
|
||||
subscriptionResetTickInterval = 1 * time.Minute
|
||||
subscriptionResetBatchSize = 300
|
||||
subscriptionCleanupInterval = 30 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
subscriptionResetOnce sync.Once
|
||||
subscriptionResetRunning atomic.Bool
|
||||
subscriptionCleanupLast atomic.Int64
|
||||
)
|
||||
|
||||
func StartSubscriptionQuotaResetTask() {
|
||||
subscriptionResetOnce.Do(func() {
|
||||
if !common.IsMasterNode {
|
||||
return
|
||||
}
|
||||
gopool.Go(func() {
|
||||
logger.LogInfo(context.Background(), fmt.Sprintf("subscription quota reset task started: tick=%s", subscriptionResetTickInterval))
|
||||
ticker := time.NewTicker(subscriptionResetTickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
runSubscriptionQuotaResetOnce()
|
||||
for range ticker.C {
|
||||
runSubscriptionQuotaResetOnce()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func runSubscriptionQuotaResetOnce() {
|
||||
if !subscriptionResetRunning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer subscriptionResetRunning.Store(false)
|
||||
|
||||
ctx := context.Background()
|
||||
totalReset := 0
|
||||
totalExpired := 0
|
||||
for {
|
||||
n, err := model.ExpireDueSubscriptions(subscriptionResetBatchSize)
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("subscription expire task failed: %v", err))
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
totalExpired += n
|
||||
if n < subscriptionResetBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
for {
|
||||
n, err := model.ResetDueSubscriptions(subscriptionResetBatchSize)
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("subscription quota reset task failed: %v", err))
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
totalReset += n
|
||||
if n < subscriptionResetBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
lastCleanup := time.Unix(subscriptionCleanupLast.Load(), 0)
|
||||
if time.Since(lastCleanup) >= subscriptionCleanupInterval {
|
||||
if _, err := model.CleanupSubscriptionPreConsumeRecords(7 * 24 * 3600); err == nil {
|
||||
subscriptionCleanupLast.Store(time.Now().Unix())
|
||||
}
|
||||
}
|
||||
if common.DebugEnabled && (totalReset > 0 || totalExpired > 0) {
|
||||
logger.LogDebug(ctx, "subscription maintenance: reset_count=%d, expired_count=%d", totalReset, totalExpired)
|
||||
}
|
||||
}
|
||||
11
service/task.go
Normal file
11
service/task.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
)
|
||||
|
||||
func CoverTaskActionToModelName(platform constant.TaskPlatform, action string) string {
|
||||
return strings.ToLower(string(platform)) + "_" + strings.ToLower(action)
|
||||
}
|
||||
285
service/task_billing.go
Normal file
285
service/task_billing.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LogTaskConsumption 记录任务消费日志和统计信息(仅记录,不涉及实际扣费)。
|
||||
// 实际扣费已由 BillingSession(PreConsumeBilling + SettleBilling)完成。
|
||||
func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
|
||||
tokenName := c.GetString("token_name")
|
||||
logContent := fmt.Sprintf("操作 %s", info.Action)
|
||||
// 支持任务仅按次计费
|
||||
if common.StringsContains(constant.TaskPricePatches, info.OriginModelName) {
|
||||
logContent = fmt.Sprintf("%s,按次计费", logContent)
|
||||
} else {
|
||||
if len(info.PriceData.OtherRatios) > 0 {
|
||||
var contents []string
|
||||
for key, ra := range info.PriceData.OtherRatios {
|
||||
if 1.0 != ra {
|
||||
contents = append(contents, fmt.Sprintf("%s: %.2f", key, ra))
|
||||
}
|
||||
}
|
||||
if len(contents) > 0 {
|
||||
logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
other := make(map[string]interface{})
|
||||
other["request_path"] = c.Request.URL.Path
|
||||
other["model_price"] = info.PriceData.ModelPrice
|
||||
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
|
||||
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
|
||||
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
|
||||
}
|
||||
if info.IsModelMapped {
|
||||
other["is_model_mapped"] = true
|
||||
other["upstream_model_name"] = info.UpstreamModelName
|
||||
}
|
||||
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
|
||||
ChannelId: info.ChannelId,
|
||||
ModelName: info.OriginModelName,
|
||||
TokenName: tokenName,
|
||||
Quota: info.PriceData.Quota,
|
||||
Content: logContent,
|
||||
TokenId: info.TokenId,
|
||||
Group: info.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
model.UpdateUserUsedQuotaAndRequestCount(info.UserId, info.PriceData.Quota)
|
||||
model.UpdateChannelUsedQuota(info.ChannelId, info.PriceData.Quota)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 异步任务计费辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// resolveTokenKey 通过 TokenId 运行时获取令牌 Key(用于 Redis 缓存操作)。
|
||||
// 如果令牌已被删除或查询失败,返回空字符串。
|
||||
func resolveTokenKey(ctx context.Context, tokenId int, taskID string) string {
|
||||
token, err := model.GetTokenById(tokenId)
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("获取令牌 key 失败 (tokenId=%d, task=%s): %s", tokenId, taskID, err.Error()))
|
||||
return ""
|
||||
}
|
||||
return token.Key
|
||||
}
|
||||
|
||||
// taskIsSubscription 判断任务是否通过订阅计费。
|
||||
func taskIsSubscription(task *model.Task) bool {
|
||||
return task.PrivateData.BillingSource == BillingSourceSubscription && task.PrivateData.SubscriptionId > 0
|
||||
}
|
||||
|
||||
// taskAdjustFunding 调整任务的资金来源(钱包或订阅),delta > 0 表示扣费,delta < 0 表示退还。
|
||||
func taskAdjustFunding(task *model.Task, delta int) error {
|
||||
if taskIsSubscription(task) {
|
||||
return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
|
||||
}
|
||||
if delta > 0 {
|
||||
return model.DecreaseUserQuota(task.UserId, delta)
|
||||
}
|
||||
return model.IncreaseUserQuota(task.UserId, -delta, false)
|
||||
}
|
||||
|
||||
// taskAdjustTokenQuota 调整任务的令牌额度,delta > 0 表示扣费,delta < 0 表示退还。
|
||||
// 需要通过 resolveTokenKey 运行时获取 key(不从 PrivateData 中读取)。
|
||||
func taskAdjustTokenQuota(ctx context.Context, task *model.Task, delta int) {
|
||||
if task.PrivateData.TokenId <= 0 || delta == 0 {
|
||||
return
|
||||
}
|
||||
tokenKey := resolveTokenKey(ctx, task.PrivateData.TokenId, task.TaskID)
|
||||
if tokenKey == "" {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if delta > 0 {
|
||||
err = model.DecreaseTokenQuota(task.PrivateData.TokenId, tokenKey, delta)
|
||||
} else {
|
||||
err = model.IncreaseTokenQuota(task.PrivateData.TokenId, tokenKey, -delta)
|
||||
}
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("调整令牌额度失败 (delta=%d, task=%s): %s", delta, task.TaskID, err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
// taskBillingOther 从 task 的 BillingContext 构建日志 Other 字段。
|
||||
func taskBillingOther(task *model.Task) map[string]interface{} {
|
||||
other := make(map[string]interface{})
|
||||
if bc := task.PrivateData.BillingContext; bc != nil {
|
||||
other["model_price"] = bc.ModelPrice
|
||||
other["group_ratio"] = bc.GroupRatio
|
||||
if len(bc.OtherRatios) > 0 {
|
||||
for k, v := range bc.OtherRatios {
|
||||
other[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
props := task.Properties
|
||||
if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName {
|
||||
other["is_model_mapped"] = true
|
||||
other["upstream_model_name"] = props.UpstreamModelName
|
||||
}
|
||||
return other
|
||||
}
|
||||
|
||||
// taskModelName 从 BillingContext 或 Properties 中获取模型名称。
|
||||
func taskModelName(task *model.Task) string {
|
||||
if bc := task.PrivateData.BillingContext; bc != nil && bc.OriginModelName != "" {
|
||||
return bc.OriginModelName
|
||||
}
|
||||
return task.Properties.OriginModelName
|
||||
}
|
||||
|
||||
// RefundTaskQuota 统一的任务失败退款逻辑。
|
||||
// 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。
|
||||
func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
|
||||
quota := task.Quota
|
||||
if quota == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 退还资金来源(钱包或订阅)
|
||||
if err := taskAdjustFunding(task, -quota); err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("退还资金来源失败 task %s: %s", task.TaskID, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 退还令牌额度
|
||||
taskAdjustTokenQuota(ctx, task, -quota)
|
||||
|
||||
// 3. 记录日志
|
||||
other := taskBillingOther(task)
|
||||
other["task_id"] = task.TaskID
|
||||
other["reason"] = reason
|
||||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||||
UserId: task.UserId,
|
||||
LogType: model.LogTypeRefund,
|
||||
Content: "",
|
||||
ChannelId: task.ChannelId,
|
||||
ModelName: taskModelName(task),
|
||||
Quota: quota,
|
||||
TokenId: task.PrivateData.TokenId,
|
||||
Group: task.Group,
|
||||
Other: other,
|
||||
})
|
||||
}
|
||||
|
||||
// RecalculateTaskQuota 通用的异步差额结算。
|
||||
// actualQuota 是任务完成后的实际应扣额度,与预扣额度 (task.Quota) 做差额结算。
|
||||
// reason 用于日志记录(例如 "token重算" 或 "adaptor调整")。
|
||||
func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int, reason string) {
|
||||
if actualQuota <= 0 {
|
||||
return
|
||||
}
|
||||
preConsumedQuota := task.Quota
|
||||
quotaDelta := actualQuota - preConsumedQuota
|
||||
|
||||
if quotaDelta == 0 {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)",
|
||||
task.TaskID, logger.LogQuota(actualQuota), reason))
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 差额结算:delta=%s(实际:%s,预扣:%s,%s)",
|
||||
task.TaskID,
|
||||
logger.LogQuota(quotaDelta),
|
||||
logger.LogQuota(actualQuota),
|
||||
logger.LogQuota(preConsumedQuota),
|
||||
reason,
|
||||
))
|
||||
|
||||
// 调整资金来源
|
||||
if err := taskAdjustFunding(task, quotaDelta); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("差额结算资金调整失败 task %s: %s", task.TaskID, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 调整令牌额度
|
||||
taskAdjustTokenQuota(ctx, task, quotaDelta)
|
||||
|
||||
task.Quota = actualQuota
|
||||
|
||||
var logType int
|
||||
var logQuota int
|
||||
if quotaDelta > 0 {
|
||||
logType = model.LogTypeConsume
|
||||
logQuota = quotaDelta
|
||||
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
|
||||
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
|
||||
} else {
|
||||
logType = model.LogTypeRefund
|
||||
logQuota = -quotaDelta
|
||||
}
|
||||
other := taskBillingOther(task)
|
||||
other["task_id"] = task.TaskID
|
||||
//other["reason"] = reason
|
||||
other["pre_consumed_quota"] = preConsumedQuota
|
||||
other["actual_quota"] = actualQuota
|
||||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||||
UserId: task.UserId,
|
||||
LogType: logType,
|
||||
Content: reason,
|
||||
ChannelId: task.ChannelId,
|
||||
ModelName: taskModelName(task),
|
||||
Quota: logQuota,
|
||||
TokenId: task.PrivateData.TokenId,
|
||||
Group: task.Group,
|
||||
Other: other,
|
||||
})
|
||||
}
|
||||
|
||||
// RecalculateTaskQuotaByTokens 根据实际 token 消耗重新计费(异步差额结算)。
|
||||
// 当任务成功且返回了 totalTokens 时,根据模型倍率和分组倍率重新计算实际扣费额度,
|
||||
// 与预扣费的差额进行补扣或退还。支持钱包和订阅计费来源。
|
||||
func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTokens int) {
|
||||
if totalTokens <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
modelName := taskModelName(task)
|
||||
|
||||
// 获取模型价格和倍率
|
||||
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
|
||||
// 只有配置了倍率(非固定价格)时才按 token 重新计费
|
||||
if !hasRatioSetting || modelRatio <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户和组的倍率信息
|
||||
group := task.Group
|
||||
if group == "" {
|
||||
user, err := model.GetUserById(task.UserId, false)
|
||||
if err == nil {
|
||||
group = user.Group
|
||||
}
|
||||
}
|
||||
if group == "" {
|
||||
return
|
||||
}
|
||||
|
||||
groupRatio := ratio_setting.GetGroupRatio(group)
|
||||
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
|
||||
|
||||
var finalGroupRatio float64
|
||||
if hasUserGroupRatio {
|
||||
finalGroupRatio = userGroupRatio
|
||||
} else {
|
||||
finalGroupRatio = groupRatio
|
||||
}
|
||||
|
||||
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
|
||||
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio)
|
||||
|
||||
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
|
||||
RecalculateTaskQuota(ctx, task, actualQuota, reason)
|
||||
}
|
||||
714
service/task_billing_test.go
Normal file
714
service/task_billing_test.go
Normal file
@@ -0,0 +1,714 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to open test db: " + err.Error())
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
panic("failed to get sql.DB: " + err.Error())
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
model.DB = db
|
||||
model.LOG_DB = db
|
||||
|
||||
common.UsingSQLite = true
|
||||
common.RedisEnabled = false
|
||||
common.BatchUpdateEnabled = false
|
||||
common.LogConsumeEnabled = true
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&model.Task{},
|
||||
&model.User{},
|
||||
&model.Token{},
|
||||
&model.Log{},
|
||||
&model.Channel{},
|
||||
&model.UserSubscription{},
|
||||
); err != nil {
|
||||
panic("failed to migrate: " + err.Error())
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func truncate(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
model.DB.Exec("DELETE FROM tasks")
|
||||
model.DB.Exec("DELETE FROM users")
|
||||
model.DB.Exec("DELETE FROM tokens")
|
||||
model.DB.Exec("DELETE FROM logs")
|
||||
model.DB.Exec("DELETE FROM channels")
|
||||
model.DB.Exec("DELETE FROM user_subscriptions")
|
||||
})
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, id int, quota int) {
|
||||
t.Helper()
|
||||
user := &model.User{Id: id, Username: "test_user", Quota: quota, Status: common.UserStatusEnabled}
|
||||
require.NoError(t, model.DB.Create(user).Error)
|
||||
}
|
||||
|
||||
func seedToken(t *testing.T, id int, userId int, key string, remainQuota int) {
|
||||
t.Helper()
|
||||
token := &model.Token{
|
||||
Id: id,
|
||||
UserId: userId,
|
||||
Key: key,
|
||||
Name: "test_token",
|
||||
Status: common.TokenStatusEnabled,
|
||||
RemainQuota: remainQuota,
|
||||
UsedQuota: 0,
|
||||
}
|
||||
require.NoError(t, model.DB.Create(token).Error)
|
||||
}
|
||||
|
||||
func seedSubscription(t *testing.T, id int, userId int, amountTotal int64, amountUsed int64) {
|
||||
t.Helper()
|
||||
sub := &model.UserSubscription{
|
||||
Id: id,
|
||||
UserId: userId,
|
||||
AmountTotal: amountTotal,
|
||||
AmountUsed: amountUsed,
|
||||
Status: "active",
|
||||
StartTime: time.Now().Unix(),
|
||||
EndTime: time.Now().Add(30 * 24 * time.Hour).Unix(),
|
||||
}
|
||||
require.NoError(t, model.DB.Create(sub).Error)
|
||||
}
|
||||
|
||||
func seedChannel(t *testing.T, id int) {
|
||||
t.Helper()
|
||||
ch := &model.Channel{Id: id, Name: "test_channel", Key: "sk-test", Status: common.ChannelStatusEnabled}
|
||||
require.NoError(t, model.DB.Create(ch).Error)
|
||||
}
|
||||
|
||||
func makeTask(userId, channelId, quota, tokenId int, billingSource string, subscriptionId int) *model.Task {
|
||||
return &model.Task{
|
||||
TaskID: "task_" + time.Now().Format("150405.000"),
|
||||
UserId: userId,
|
||||
ChannelId: channelId,
|
||||
Quota: quota,
|
||||
Status: model.TaskStatus(model.TaskStatusInProgress),
|
||||
Group: "default",
|
||||
Data: json.RawMessage(`{}`),
|
||||
CreatedAt: time.Now().Unix(),
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
Properties: model.Properties{
|
||||
OriginModelName: "test-model",
|
||||
},
|
||||
PrivateData: model.TaskPrivateData{
|
||||
BillingSource: billingSource,
|
||||
SubscriptionId: subscriptionId,
|
||||
TokenId: tokenId,
|
||||
BillingContext: &model.TaskBillingContext{
|
||||
ModelPrice: 0.02,
|
||||
GroupRatio: 1.0,
|
||||
OriginModelName: "test-model",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-back helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func getUserQuota(t *testing.T, id int) int {
|
||||
t.Helper()
|
||||
var user model.User
|
||||
require.NoError(t, model.DB.Select("quota").Where("id = ?", id).First(&user).Error)
|
||||
return user.Quota
|
||||
}
|
||||
|
||||
func getTokenRemainQuota(t *testing.T, id int) int {
|
||||
t.Helper()
|
||||
var token model.Token
|
||||
require.NoError(t, model.DB.Select("remain_quota").Where("id = ?", id).First(&token).Error)
|
||||
return token.RemainQuota
|
||||
}
|
||||
|
||||
func getTokenUsedQuota(t *testing.T, id int) int {
|
||||
t.Helper()
|
||||
var token model.Token
|
||||
require.NoError(t, model.DB.Select("used_quota").Where("id = ?", id).First(&token).Error)
|
||||
return token.UsedQuota
|
||||
}
|
||||
|
||||
func getSubscriptionUsed(t *testing.T, id int) int64 {
|
||||
t.Helper()
|
||||
var sub model.UserSubscription
|
||||
require.NoError(t, model.DB.Select("amount_used").Where("id = ?", id).First(&sub).Error)
|
||||
return sub.AmountUsed
|
||||
}
|
||||
|
||||
func getLastLog(t *testing.T) *model.Log {
|
||||
t.Helper()
|
||||
var log model.Log
|
||||
err := model.LOG_DB.Order("id desc").First(&log).Error
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &log
|
||||
}
|
||||
|
||||
func countLogs(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
var count int64
|
||||
model.LOG_DB.Model(&model.Log{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// RefundTaskQuota tests
|
||||
// ===========================================================================
|
||||
|
||||
func TestRefundTaskQuota_Wallet(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID = 1, 1, 1
|
||||
const initQuota, preConsumed = 10000, 3000
|
||||
const tokenRemain = 5000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedToken(t, tokenID, userID, "sk-test-key", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
|
||||
|
||||
RefundTaskQuota(ctx, task, "task failed: upstream error")
|
||||
|
||||
// User quota should increase by preConsumed
|
||||
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
|
||||
|
||||
// Token remain_quota should increase, used_quota should decrease
|
||||
assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
|
||||
assert.Equal(t, -preConsumed, getTokenUsedQuota(t, tokenID))
|
||||
|
||||
// A refund log should be created
|
||||
log := getLastLog(t)
|
||||
require.NotNil(t, log)
|
||||
assert.Equal(t, model.LogTypeRefund, log.Type)
|
||||
assert.Equal(t, preConsumed, log.Quota)
|
||||
assert.Equal(t, "test-model", log.ModelName)
|
||||
}
|
||||
|
||||
func TestRefundTaskQuota_Subscription(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID, subID = 2, 2, 2, 1
|
||||
const preConsumed = 2000
|
||||
const subTotal, subUsed int64 = 100000, 50000
|
||||
const tokenRemain = 8000
|
||||
|
||||
seedUser(t, userID, 0)
|
||||
seedToken(t, tokenID, userID, "sk-sub-key", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
seedSubscription(t, subID, userID, subTotal, subUsed)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID)
|
||||
|
||||
RefundTaskQuota(ctx, task, "subscription task failed")
|
||||
|
||||
// Subscription used should decrease by preConsumed
|
||||
assert.Equal(t, subUsed-int64(preConsumed), getSubscriptionUsed(t, subID))
|
||||
|
||||
// Token should also be refunded
|
||||
assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
|
||||
|
||||
log := getLastLog(t)
|
||||
require.NotNil(t, log)
|
||||
assert.Equal(t, model.LogTypeRefund, log.Type)
|
||||
}
|
||||
|
||||
func TestRefundTaskQuota_ZeroQuota(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID = 3
|
||||
seedUser(t, userID, 5000)
|
||||
|
||||
task := makeTask(userID, 0, 0, 0, BillingSourceWallet, 0)
|
||||
|
||||
RefundTaskQuota(ctx, task, "zero quota task")
|
||||
|
||||
// No change to user quota
|
||||
assert.Equal(t, 5000, getUserQuota(t, userID))
|
||||
|
||||
// No log created
|
||||
assert.Equal(t, int64(0), countLogs(t))
|
||||
}
|
||||
|
||||
func TestRefundTaskQuota_NoToken(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, channelID = 4, 4
|
||||
const initQuota, preConsumed = 10000, 1500
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, 0, BillingSourceWallet, 0) // TokenId=0
|
||||
|
||||
RefundTaskQuota(ctx, task, "no token task failed")
|
||||
|
||||
// User quota refunded
|
||||
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
|
||||
|
||||
// Log created
|
||||
log := getLastLog(t)
|
||||
require.NotNil(t, log)
|
||||
assert.Equal(t, model.LogTypeRefund, log.Type)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// RecalculateTaskQuota tests
|
||||
// ===========================================================================
|
||||
|
||||
func TestRecalculate_PositiveDelta(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID = 10, 10, 10
|
||||
const initQuota, preConsumed = 10000, 2000
|
||||
const actualQuota = 3000 // under-charged by 1000
|
||||
const tokenRemain = 5000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedToken(t, tokenID, userID, "sk-recalc-pos", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
|
||||
|
||||
RecalculateTaskQuota(ctx, task, actualQuota, "adaptor adjustment")
|
||||
|
||||
// User quota should decrease by the delta (1000 additional charge)
|
||||
assert.Equal(t, initQuota-(actualQuota-preConsumed), getUserQuota(t, userID))
|
||||
|
||||
// Token should also be charged the delta
|
||||
assert.Equal(t, tokenRemain-(actualQuota-preConsumed), getTokenRemainQuota(t, tokenID))
|
||||
|
||||
// task.Quota should be updated to actualQuota
|
||||
assert.Equal(t, actualQuota, task.Quota)
|
||||
|
||||
// Log type should be Consume (additional charge)
|
||||
log := getLastLog(t)
|
||||
require.NotNil(t, log)
|
||||
assert.Equal(t, model.LogTypeConsume, log.Type)
|
||||
assert.Equal(t, actualQuota-preConsumed, log.Quota)
|
||||
}
|
||||
|
||||
func TestRecalculate_NegativeDelta(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID = 11, 11, 11
|
||||
const initQuota, preConsumed = 10000, 5000
|
||||
const actualQuota = 3000 // over-charged by 2000
|
||||
const tokenRemain = 5000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedToken(t, tokenID, userID, "sk-recalc-neg", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
|
||||
|
||||
RecalculateTaskQuota(ctx, task, actualQuota, "adaptor adjustment")
|
||||
|
||||
// User quota should increase by abs(delta) = 2000 (refund overpayment)
|
||||
assert.Equal(t, initQuota+(preConsumed-actualQuota), getUserQuota(t, userID))
|
||||
|
||||
// Token should be refunded the difference
|
||||
assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
|
||||
|
||||
// task.Quota updated
|
||||
assert.Equal(t, actualQuota, task.Quota)
|
||||
|
||||
// Log type should be Refund
|
||||
log := getLastLog(t)
|
||||
require.NotNil(t, log)
|
||||
assert.Equal(t, model.LogTypeRefund, log.Type)
|
||||
assert.Equal(t, preConsumed-actualQuota, log.Quota)
|
||||
}
|
||||
|
||||
func TestRecalculate_ZeroDelta(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID = 12
|
||||
const initQuota, preConsumed = 10000, 3000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
|
||||
task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
|
||||
|
||||
RecalculateTaskQuota(ctx, task, preConsumed, "exact match")
|
||||
|
||||
// No change to user quota
|
||||
assert.Equal(t, initQuota, getUserQuota(t, userID))
|
||||
|
||||
// No log created (delta is zero)
|
||||
assert.Equal(t, int64(0), countLogs(t))
|
||||
}
|
||||
|
||||
func TestRecalculate_ActualQuotaZero(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID = 13
|
||||
const initQuota = 10000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
|
||||
task := makeTask(userID, 0, 5000, 0, BillingSourceWallet, 0)
|
||||
|
||||
RecalculateTaskQuota(ctx, task, 0, "zero actual")
|
||||
|
||||
// No change (early return)
|
||||
assert.Equal(t, initQuota, getUserQuota(t, userID))
|
||||
assert.Equal(t, int64(0), countLogs(t))
|
||||
}
|
||||
|
||||
func TestRecalculate_Subscription_NegativeDelta(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID, subID = 14, 14, 14, 2
|
||||
const preConsumed = 5000
|
||||
const actualQuota = 2000 // over-charged by 3000
|
||||
const subTotal, subUsed int64 = 100000, 50000
|
||||
const tokenRemain = 8000
|
||||
|
||||
seedUser(t, userID, 0)
|
||||
seedToken(t, tokenID, userID, "sk-sub-recalc", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
seedSubscription(t, subID, userID, subTotal, subUsed)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID)
|
||||
|
||||
RecalculateTaskQuota(ctx, task, actualQuota, "subscription over-charge")
|
||||
|
||||
// Subscription used should decrease by delta (refund 3000)
|
||||
assert.Equal(t, subUsed-int64(preConsumed-actualQuota), getSubscriptionUsed(t, subID))
|
||||
|
||||
// Token refunded
|
||||
assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
|
||||
|
||||
assert.Equal(t, actualQuota, task.Quota)
|
||||
|
||||
log := getLastLog(t)
|
||||
require.NotNil(t, log)
|
||||
assert.Equal(t, model.LogTypeRefund, log.Type)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// CAS + Billing integration tests
|
||||
// Simulates the flow in updateVideoSingleTask (service/task_polling.go)
|
||||
// ===========================================================================
|
||||
|
||||
// simulatePollBilling reproduces the CAS + billing logic from updateVideoSingleTask.
|
||||
// It takes a persisted task (already in DB), applies the new status, and performs
|
||||
// the conditional update + billing exactly as the polling loop does.
|
||||
func simulatePollBilling(ctx context.Context, task *model.Task, newStatus model.TaskStatus, actualQuota int) {
|
||||
snap := task.Snapshot()
|
||||
|
||||
shouldRefund := false
|
||||
shouldSettle := false
|
||||
quota := task.Quota
|
||||
|
||||
task.Status = newStatus
|
||||
switch string(newStatus) {
|
||||
case model.TaskStatusSuccess:
|
||||
task.Progress = "100%"
|
||||
task.FinishTime = 9999
|
||||
shouldSettle = true
|
||||
case model.TaskStatusFailure:
|
||||
task.Progress = "100%"
|
||||
task.FinishTime = 9999
|
||||
task.FailReason = "upstream error"
|
||||
if quota != 0 {
|
||||
shouldRefund = true
|
||||
}
|
||||
default:
|
||||
task.Progress = "50%"
|
||||
}
|
||||
|
||||
isDone := task.Status == model.TaskStatus(model.TaskStatusSuccess) || task.Status == model.TaskStatus(model.TaskStatusFailure)
|
||||
if isDone && snap.Status != task.Status {
|
||||
won, err := task.UpdateWithStatus(snap.Status)
|
||||
if err != nil {
|
||||
shouldRefund = false
|
||||
shouldSettle = false
|
||||
} else if !won {
|
||||
shouldRefund = false
|
||||
shouldSettle = false
|
||||
}
|
||||
} else if !snap.Equal(task.Snapshot()) {
|
||||
_, _ = task.UpdateWithStatus(snap.Status)
|
||||
}
|
||||
|
||||
if shouldSettle && actualQuota > 0 {
|
||||
RecalculateTaskQuota(ctx, task, actualQuota, "test settle")
|
||||
}
|
||||
if shouldRefund {
|
||||
RefundTaskQuota(ctx, task, task.FailReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCASGuardedRefund_Win(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID = 20, 20, 20
|
||||
const initQuota, preConsumed = 10000, 4000
|
||||
const tokenRemain = 6000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedToken(t, tokenID, userID, "sk-cas-refund-win", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
|
||||
task.Status = model.TaskStatus(model.TaskStatusInProgress)
|
||||
require.NoError(t, model.DB.Create(task).Error)
|
||||
|
||||
simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusFailure), 0)
|
||||
|
||||
// CAS wins: task in DB should now be FAILURE
|
||||
var reloaded model.Task
|
||||
require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
|
||||
assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
|
||||
|
||||
// Refund should have happened
|
||||
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
|
||||
assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
|
||||
|
||||
log := getLastLog(t)
|
||||
require.NotNil(t, log)
|
||||
assert.Equal(t, model.LogTypeRefund, log.Type)
|
||||
}
|
||||
|
||||
func TestCASGuardedRefund_Lose(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID = 21, 21, 21
|
||||
const initQuota, preConsumed = 10000, 4000
|
||||
const tokenRemain = 6000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedToken(t, tokenID, userID, "sk-cas-refund-lose", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
// Create task with IN_PROGRESS in DB
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
|
||||
task.Status = model.TaskStatus(model.TaskStatusInProgress)
|
||||
require.NoError(t, model.DB.Create(task).Error)
|
||||
|
||||
// Simulate another process already transitioning to FAILURE
|
||||
model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Update("status", model.TaskStatusFailure)
|
||||
|
||||
// Our process still has the old in-memory state (IN_PROGRESS) and tries to transition
|
||||
// task.Status is still IN_PROGRESS in the snapshot
|
||||
simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusFailure), 0)
|
||||
|
||||
// CAS lost: user quota should NOT change (no double refund)
|
||||
assert.Equal(t, initQuota, getUserQuota(t, userID))
|
||||
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
|
||||
|
||||
// No billing log should be created
|
||||
assert.Equal(t, int64(0), countLogs(t))
|
||||
}
|
||||
|
||||
func TestCASGuardedSettle_Win(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID = 22, 22, 22
|
||||
const initQuota, preConsumed = 10000, 5000
|
||||
const actualQuota = 3000 // over-charged, should get partial refund
|
||||
const tokenRemain = 8000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedToken(t, tokenID, userID, "sk-cas-settle-win", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
|
||||
task.Status = model.TaskStatus(model.TaskStatusInProgress)
|
||||
require.NoError(t, model.DB.Create(task).Error)
|
||||
|
||||
simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusSuccess), actualQuota)
|
||||
|
||||
// CAS wins: task should be SUCCESS
|
||||
var reloaded model.Task
|
||||
require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
|
||||
assert.EqualValues(t, model.TaskStatusSuccess, reloaded.Status)
|
||||
|
||||
// Settlement should refund the over-charge (5000 - 3000 = 2000 back to user)
|
||||
assert.Equal(t, initQuota+(preConsumed-actualQuota), getUserQuota(t, userID))
|
||||
assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
|
||||
|
||||
// task.Quota should be updated to actualQuota
|
||||
assert.Equal(t, actualQuota, task.Quota)
|
||||
}
|
||||
|
||||
func TestNonTerminalUpdate_NoBilling(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, channelID = 23, 23
|
||||
const initQuota, preConsumed = 10000, 3000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, 0, BillingSourceWallet, 0)
|
||||
task.Status = model.TaskStatus(model.TaskStatusInProgress)
|
||||
task.Progress = "20%"
|
||||
require.NoError(t, model.DB.Create(task).Error)
|
||||
|
||||
// Simulate a non-terminal poll update (still IN_PROGRESS, progress changed)
|
||||
simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusInProgress), 0)
|
||||
|
||||
// User quota should NOT change
|
||||
assert.Equal(t, initQuota, getUserQuota(t, userID))
|
||||
|
||||
// No billing log
|
||||
assert.Equal(t, int64(0), countLogs(t))
|
||||
|
||||
// Task progress should be updated in DB
|
||||
var reloaded model.Task
|
||||
require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
|
||||
assert.Equal(t, "50%", reloaded.Progress)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Mock adaptor for settleTaskBillingOnComplete tests
|
||||
// ===========================================================================
|
||||
|
||||
type mockAdaptor struct {
|
||||
adjustReturn int
|
||||
}
|
||||
|
||||
func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {}
|
||||
func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil }
|
||||
func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
|
||||
return m.adjustReturn
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PerCallBilling tests — settleTaskBillingOnComplete
|
||||
// ===========================================================================
|
||||
|
||||
func TestSettle_PerCallBilling_SkipsAdaptorAdjust(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID = 30, 30, 30
|
||||
const initQuota, preConsumed = 10000, 5000
|
||||
const tokenRemain = 8000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedToken(t, tokenID, userID, "sk-percall-adaptor", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
|
||||
task.PrivateData.BillingContext.PerCallBilling = true
|
||||
|
||||
adaptor := &mockAdaptor{adjustReturn: 2000}
|
||||
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}
|
||||
|
||||
settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
|
||||
|
||||
// Per-call: no adjustment despite adaptor returning 2000
|
||||
assert.Equal(t, initQuota, getUserQuota(t, userID))
|
||||
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
|
||||
assert.Equal(t, preConsumed, task.Quota)
|
||||
assert.Equal(t, int64(0), countLogs(t))
|
||||
}
|
||||
|
||||
func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID = 31, 31, 31
|
||||
const initQuota, preConsumed = 10000, 4000
|
||||
const tokenRemain = 7000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedToken(t, tokenID, userID, "sk-percall-tokens", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
|
||||
task.PrivateData.BillingContext.PerCallBilling = true
|
||||
|
||||
adaptor := &mockAdaptor{adjustReturn: 0}
|
||||
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999}
|
||||
|
||||
settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
|
||||
|
||||
// Per-call: no recalculation by tokens
|
||||
assert.Equal(t, initQuota, getUserQuota(t, userID))
|
||||
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
|
||||
assert.Equal(t, preConsumed, task.Quota)
|
||||
assert.Equal(t, int64(0), countLogs(t))
|
||||
}
|
||||
|
||||
func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) {
|
||||
truncate(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const userID, tokenID, channelID = 32, 32, 32
|
||||
const initQuota, preConsumed = 10000, 5000
|
||||
const adaptorQuota = 3000
|
||||
const tokenRemain = 8000
|
||||
|
||||
seedUser(t, userID, initQuota)
|
||||
seedToken(t, tokenID, userID, "sk-nonpercall-adj", tokenRemain)
|
||||
seedChannel(t, channelID)
|
||||
|
||||
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
|
||||
// PerCallBilling defaults to false
|
||||
|
||||
adaptor := &mockAdaptor{adjustReturn: adaptorQuota}
|
||||
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}
|
||||
|
||||
settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
|
||||
|
||||
// Non-per-call: adaptor adjustment applies (refund 2000)
|
||||
assert.Equal(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID))
|
||||
assert.Equal(t, tokenRemain+(preConsumed-adaptorQuota), getTokenRemainQuota(t, tokenID))
|
||||
assert.Equal(t, adaptorQuota, task.Quota)
|
||||
|
||||
log := getLastLog(t)
|
||||
require.NotNil(t, log)
|
||||
assert.Equal(t, model.LogTypeRefund, log.Type)
|
||||
}
|
||||
560
service/task_polling.go
Normal file
560
service/task_polling.go
Normal file
@@ -0,0 +1,560 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// TaskPollingAdaptor 定义轮询所需的最小适配器接口,避免 service -> relay 的循环依赖
|
||||
type TaskPollingAdaptor interface {
|
||||
Init(info *relaycommon.RelayInfo)
|
||||
FetchTask(baseURL string, key string, body map[string]any, proxy string) (*http.Response, error)
|
||||
ParseTaskResult(body []byte) (*relaycommon.TaskInfo, error)
|
||||
// AdjustBillingOnComplete 在任务到达终态(成功/失败)时由轮询循环调用。
|
||||
// 返回正数触发差额结算(补扣/退还),返回 0 保持预扣费金额不变。
|
||||
AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int
|
||||
}
|
||||
|
||||
// GetTaskAdaptorFunc 由 main 包注入,用于获取指定平台的任务适配器。
|
||||
// 打破 service -> relay -> relay/channel -> service 的循环依赖。
|
||||
var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor
|
||||
|
||||
// sweepTimedOutTasks 在主轮询之前独立清理超时任务。
|
||||
// 每次最多处理 100 条,剩余的下个周期继续处理。
|
||||
// 使用 per-task CAS (UpdateWithStatus) 防止覆盖被正常轮询已推进的任务。
|
||||
func sweepTimedOutTasks(ctx context.Context) {
|
||||
if constant.TaskTimeoutMinutes <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Unix() - int64(constant.TaskTimeoutMinutes)*60
|
||||
tasks := model.GetTimedOutUnfinishedTasks(cutoff, 100)
|
||||
if len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
const legacyTaskCutoff int64 = 1740182400 // 2026-02-22 00:00:00 UTC
|
||||
reason := fmt.Sprintf("任务超时(%d分钟)", constant.TaskTimeoutMinutes)
|
||||
legacyReason := "任务超时(旧系统遗留任务,不进行退款,请联系管理员)"
|
||||
now := time.Now().Unix()
|
||||
timedOutCount := 0
|
||||
|
||||
for _, task := range tasks {
|
||||
isLegacy := task.SubmitTime > 0 && task.SubmitTime < legacyTaskCutoff
|
||||
|
||||
oldStatus := task.Status
|
||||
task.Status = model.TaskStatusFailure
|
||||
task.Progress = "100%"
|
||||
task.FinishTime = now
|
||||
if isLegacy {
|
||||
task.FailReason = legacyReason
|
||||
} else {
|
||||
task.FailReason = reason
|
||||
}
|
||||
|
||||
won, err := task.UpdateWithStatus(oldStatus)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks CAS update error for task %s: %v", task.TaskID, err))
|
||||
continue
|
||||
}
|
||||
if !won {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("sweepTimedOutTasks: task %s already transitioned, skip", task.TaskID))
|
||||
continue
|
||||
}
|
||||
timedOutCount++
|
||||
if !isLegacy && task.Quota != 0 {
|
||||
RefundTaskQuota(ctx, task, reason)
|
||||
}
|
||||
}
|
||||
|
||||
if timedOutCount > 0 {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("sweepTimedOutTasks: timed out %d tasks", timedOutCount))
|
||||
}
|
||||
}
|
||||
|
||||
// TaskPollingLoop 主轮询循环,每 15 秒检查一次未完成的任务
|
||||
func TaskPollingLoop() {
|
||||
for {
|
||||
time.Sleep(time.Duration(15) * time.Second)
|
||||
common.SysLog("任务进度轮询开始")
|
||||
ctx := context.TODO()
|
||||
sweepTimedOutTasks(ctx)
|
||||
allTasks := model.GetAllUnFinishSyncTasks(constant.TaskQueryLimit)
|
||||
platformTask := make(map[constant.TaskPlatform][]*model.Task)
|
||||
for _, t := range allTasks {
|
||||
platformTask[t.Platform] = append(platformTask[t.Platform], t)
|
||||
}
|
||||
for platform, tasks := range platformTask {
|
||||
if len(tasks) == 0 {
|
||||
continue
|
||||
}
|
||||
taskChannelM := make(map[int][]string)
|
||||
taskM := make(map[string]*model.Task)
|
||||
nullTaskIds := make([]int64, 0)
|
||||
for _, task := range tasks {
|
||||
upstreamID := task.GetUpstreamTaskID()
|
||||
if upstreamID == "" {
|
||||
// 统计失败的未完成任务
|
||||
nullTaskIds = append(nullTaskIds, task.ID)
|
||||
continue
|
||||
}
|
||||
taskM[upstreamID] = task
|
||||
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], upstreamID)
|
||||
}
|
||||
if len(nullTaskIds) > 0 {
|
||||
err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{
|
||||
"status": "FAILURE",
|
||||
"progress": "100%",
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
|
||||
} else {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
|
||||
}
|
||||
}
|
||||
if len(taskChannelM) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
DispatchPlatformUpdate(platform, taskChannelM, taskM)
|
||||
}
|
||||
common.SysLog("任务进度轮询完成")
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchPlatformUpdate 按平台分发轮询更新
|
||||
func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) {
|
||||
switch platform {
|
||||
case constant.TaskPlatformMidjourney:
|
||||
// MJ 轮询由其自身处理,这里预留入口
|
||||
case constant.TaskPlatformSuno:
|
||||
_ = UpdateSunoTasks(context.Background(), taskChannelM, taskM)
|
||||
default:
|
||||
if err := UpdateVideoTasks(context.Background(), platform, taskChannelM, taskM); err != nil {
|
||||
common.SysLog(fmt.Sprintf("UpdateVideoTasks fail: %s", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSunoTasks 按渠道更新所有 Suno 任务
|
||||
func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
|
||||
for channelId, taskIds := range taskChannelM {
|
||||
err := updateSunoTasks(ctx, channelId, taskIds, taskM)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
|
||||
if len(taskIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
ch, err := model.CacheGetChannel(channelId)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("CacheGetChannel: %v", err))
|
||||
// Collect DB primary key IDs for bulk update (taskIds are upstream IDs, not task_id column values)
|
||||
var failedIDs []int64
|
||||
for _, upstreamID := range taskIds {
|
||||
if t, ok := taskM[upstreamID]; ok {
|
||||
failedIDs = append(failedIDs, t.ID)
|
||||
}
|
||||
}
|
||||
err = model.TaskBulkUpdateByID(failedIDs, map[string]any{
|
||||
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
|
||||
"status": "FAILURE",
|
||||
"progress": "100%",
|
||||
})
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("UpdateSunoTask error: %v", err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
adaptor := GetTaskAdaptorFunc(constant.TaskPlatformSuno)
|
||||
if adaptor == nil {
|
||||
return errors.New("adaptor not found")
|
||||
}
|
||||
proxy := ch.GetSetting().Proxy
|
||||
resp, err := adaptor.FetchTask(*ch.BaseURL, ch.Key, map[string]any{
|
||||
"ids": taskIds,
|
||||
}, proxy)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err))
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
||||
return fmt.Errorf("Get Task status code: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("Get Suno Task parse body error: %v", err))
|
||||
return err
|
||||
}
|
||||
var responseItems dto.TaskResponse[[]dto.SunoDataResponse]
|
||||
err = common.Unmarshal(responseBody, &responseItems)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Suno Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||
return err
|
||||
}
|
||||
if !responseItems.IsSuccess() {
|
||||
common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %s", channelId, len(taskIds), string(responseBody)))
|
||||
return err
|
||||
}
|
||||
|
||||
for _, responseItem := range responseItems.Data {
|
||||
task := taskM[responseItem.TaskID]
|
||||
if !taskNeedsUpdate(task, responseItem) {
|
||||
continue
|
||||
}
|
||||
|
||||
task.Status = lo.If(model.TaskStatus(responseItem.Status) != "", model.TaskStatus(responseItem.Status)).Else(task.Status)
|
||||
task.FailReason = lo.If(responseItem.FailReason != "", responseItem.FailReason).Else(task.FailReason)
|
||||
task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime)
|
||||
task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime)
|
||||
task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime)
|
||||
if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure {
|
||||
logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
|
||||
task.Progress = "100%"
|
||||
RefundTaskQuota(ctx, task, task.FailReason)
|
||||
}
|
||||
if responseItem.Status == model.TaskStatusSuccess {
|
||||
task.Progress = "100%"
|
||||
}
|
||||
task.Data = responseItem.Data
|
||||
|
||||
err = task.Update()
|
||||
if err != nil {
|
||||
common.SysLog("UpdateSunoTask task error: " + err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// taskNeedsUpdate 检查 Suno 任务是否需要更新
|
||||
func taskNeedsUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool {
|
||||
if oldTask.SubmitTime != newTask.SubmitTime {
|
||||
return true
|
||||
}
|
||||
if oldTask.StartTime != newTask.StartTime {
|
||||
return true
|
||||
}
|
||||
if oldTask.FinishTime != newTask.FinishTime {
|
||||
return true
|
||||
}
|
||||
if string(oldTask.Status) != newTask.Status {
|
||||
return true
|
||||
}
|
||||
if oldTask.FailReason != newTask.FailReason {
|
||||
return true
|
||||
}
|
||||
|
||||
if (oldTask.Status == model.TaskStatusFailure || oldTask.Status == model.TaskStatusSuccess) && oldTask.Progress != "100%" {
|
||||
return true
|
||||
}
|
||||
|
||||
oldData, _ := common.Marshal(oldTask.Data)
|
||||
newData, _ := common.Marshal(newTask.Data)
|
||||
|
||||
sort.Slice(oldData, func(i, j int) bool {
|
||||
return oldData[i] < oldData[j]
|
||||
})
|
||||
sort.Slice(newData, func(i, j int) bool {
|
||||
return newData[i] < newData[j]
|
||||
})
|
||||
|
||||
if string(oldData) != string(newData) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateVideoTasks 按渠道更新所有视频任务
|
||||
func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
|
||||
for channelId, taskIds := range taskChannelM {
|
||||
if err := updateVideoTasks(ctx, platform, channelId, taskIds, taskM); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
|
||||
if len(taskIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
cacheGetChannel, err := model.CacheGetChannel(channelId)
|
||||
if err != nil {
|
||||
// Collect DB primary key IDs for bulk update (taskIds are upstream IDs, not task_id column values)
|
||||
var failedIDs []int64
|
||||
for _, upstreamID := range taskIds {
|
||||
if t, ok := taskM[upstreamID]; ok {
|
||||
failedIDs = append(failedIDs, t.ID)
|
||||
}
|
||||
}
|
||||
errUpdate := model.TaskBulkUpdateByID(failedIDs, map[string]any{
|
||||
"fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId),
|
||||
"status": "FAILURE",
|
||||
"progress": "100%",
|
||||
})
|
||||
if errUpdate != nil {
|
||||
common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
|
||||
}
|
||||
return fmt.Errorf("CacheGetChannel failed: %w", err)
|
||||
}
|
||||
adaptor := GetTaskAdaptorFunc(platform)
|
||||
if adaptor == nil {
|
||||
return fmt.Errorf("video adaptor not found")
|
||||
}
|
||||
info := &relaycommon.RelayInfo{}
|
||||
info.ChannelMeta = &relaycommon.ChannelMeta{
|
||||
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
|
||||
}
|
||||
info.ApiKey = cacheGetChannel.Key
|
||||
adaptor.Init(info)
|
||||
for _, taskId := range taskIds {
|
||||
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
|
||||
}
|
||||
// sleep 1 second between each task to avoid hitting rate limits of upstream platforms
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *model.Channel, taskId string, taskM map[string]*model.Task) error {
|
||||
baseURL := constant.ChannelBaseURLs[ch.Type]
|
||||
if ch.GetBaseURL() != "" {
|
||||
baseURL = ch.GetBaseURL()
|
||||
}
|
||||
proxy := ch.GetSetting().Proxy
|
||||
|
||||
task := taskM[taskId]
|
||||
if task == nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
|
||||
return fmt.Errorf("task %s not found", taskId)
|
||||
}
|
||||
key := ch.Key
|
||||
|
||||
privateData := task.PrivateData
|
||||
if privateData.Key != "" {
|
||||
key = privateData.Key
|
||||
}
|
||||
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
|
||||
"task_id": task.GetUpstreamTaskID(),
|
||||
"action": task.Action,
|
||||
}, proxy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask response: %s", string(responseBody)))
|
||||
|
||||
snap := task.Snapshot()
|
||||
|
||||
taskResult := &relaycommon.TaskInfo{}
|
||||
// try parse as New API response format
|
||||
var responseItems dto.TaskResponse[model.Task]
|
||||
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
|
||||
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask parsed as new api response format: %+v", responseItems))
|
||||
t := responseItems.Data
|
||||
taskResult.TaskID = t.TaskID
|
||||
taskResult.Status = string(t.Status)
|
||||
taskResult.Url = t.GetResultURL()
|
||||
taskResult.Progress = t.Progress
|
||||
taskResult.Reason = t.FailReason
|
||||
task.Data = t.Data
|
||||
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
|
||||
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
task.Data = redactVideoResponseBody(responseBody)
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask taskResult: %+v", taskResult))
|
||||
|
||||
now := time.Now().Unix()
|
||||
if taskResult.Status == "" {
|
||||
//taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
|
||||
errorResult := &dto.GeneralErrorResponse{}
|
||||
if err = common.Unmarshal(responseBody, &errorResult); err == nil {
|
||||
openaiError := errorResult.TryToOpenAIError()
|
||||
if openaiError != nil {
|
||||
// 返回规范的 OpenAI 错误格式,提取错误信息,判断错误是否为任务失败
|
||||
if openaiError.Code == "429" {
|
||||
// 429 错误通常表示请求过多或速率限制,暂时不认为是任务失败,保持原状态等待下一轮轮询
|
||||
return nil
|
||||
}
|
||||
|
||||
// 其他错误认为是任务失败,记录错误信息并更新任务状态
|
||||
taskResult = relaycommon.FailTaskInfo("upstream returned error")
|
||||
} else {
|
||||
// unknown error format, log original response
|
||||
logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", taskId, string(responseBody)))
|
||||
taskResult = relaycommon.FailTaskInfo("upstream returned unrecognized message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shouldRefund := false
|
||||
shouldSettle := false
|
||||
quota := task.Quota
|
||||
|
||||
task.Status = model.TaskStatus(taskResult.Status)
|
||||
switch taskResult.Status {
|
||||
case model.TaskStatusSubmitted:
|
||||
task.Progress = taskcommon.ProgressSubmitted
|
||||
case model.TaskStatusQueued:
|
||||
task.Progress = taskcommon.ProgressQueued
|
||||
case model.TaskStatusInProgress:
|
||||
task.Progress = taskcommon.ProgressInProgress
|
||||
if task.StartTime == 0 {
|
||||
task.StartTime = now
|
||||
}
|
||||
case model.TaskStatusSuccess:
|
||||
task.Progress = taskcommon.ProgressComplete
|
||||
if task.FinishTime == 0 {
|
||||
task.FinishTime = now
|
||||
}
|
||||
if strings.HasPrefix(taskResult.Url, "data:") {
|
||||
// data: URI (e.g. Vertex base64 encoded video) — keep in Data, not in ResultURL
|
||||
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
|
||||
} else if taskResult.Url != "" {
|
||||
// Direct upstream URL (e.g. Kling, Ali, Doubao, etc.)
|
||||
task.PrivateData.ResultURL = taskResult.Url
|
||||
} else {
|
||||
// No URL from adaptor — construct proxy URL using public task ID
|
||||
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
|
||||
}
|
||||
shouldSettle = true
|
||||
case model.TaskStatusFailure:
|
||||
logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
|
||||
task.Status = model.TaskStatusFailure
|
||||
task.Progress = taskcommon.ProgressComplete
|
||||
if task.FinishTime == 0 {
|
||||
task.FinishTime = now
|
||||
}
|
||||
task.FailReason = taskResult.Reason
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
|
||||
taskResult.Progress = taskcommon.ProgressComplete
|
||||
if quota != 0 {
|
||||
shouldRefund = true
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, task.TaskID)
|
||||
}
|
||||
if taskResult.Progress != "" {
|
||||
task.Progress = taskResult.Progress
|
||||
}
|
||||
|
||||
isDone := task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure
|
||||
if isDone && snap.Status != task.Status {
|
||||
won, err := task.UpdateWithStatus(snap.Status)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("UpdateWithStatus failed for task %s: %s", task.TaskID, err.Error()))
|
||||
shouldRefund = false
|
||||
shouldSettle = false
|
||||
} else if !won {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Task %s already transitioned by another process, skip billing", task.TaskID))
|
||||
shouldRefund = false
|
||||
shouldSettle = false
|
||||
}
|
||||
} else if !snap.Equal(task.Snapshot()) {
|
||||
if _, err := task.UpdateWithStatus(snap.Status); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Failed to update task %s: %s", task.TaskID, err.Error()))
|
||||
}
|
||||
} else {
|
||||
// No changes, skip update
|
||||
logger.LogDebug(ctx, fmt.Sprintf("No update needed for task %s", task.TaskID))
|
||||
}
|
||||
|
||||
if shouldSettle {
|
||||
settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
|
||||
}
|
||||
if shouldRefund {
|
||||
RefundTaskQuota(ctx, task, task.FailReason)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func redactVideoResponseBody(body []byte) []byte {
|
||||
var m map[string]any
|
||||
if err := common.Unmarshal(body, &m); err != nil {
|
||||
return body
|
||||
}
|
||||
resp, _ := m["response"].(map[string]any)
|
||||
if resp != nil {
|
||||
delete(resp, "bytesBase64Encoded")
|
||||
if v, ok := resp["video"].(string); ok {
|
||||
resp["video"] = truncateBase64(v)
|
||||
}
|
||||
if vs, ok := resp["videos"].([]any); ok {
|
||||
for i := range vs {
|
||||
if vm, ok := vs[i].(map[string]any); ok {
|
||||
delete(vm, "bytesBase64Encoded")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b, err := common.Marshal(m)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func truncateBase64(s string) string {
|
||||
const maxKeep = 256
|
||||
if len(s) <= maxKeep {
|
||||
return s
|
||||
}
|
||||
return s[:maxKeep] + "..."
|
||||
}
|
||||
|
||||
// settleTaskBillingOnComplete 任务完成时的统一计费调整。
|
||||
// 优先级:1. adaptor.AdjustBillingOnComplete 返回正数 → 使用 adaptor 计算的额度
|
||||
//
|
||||
// 2. taskResult.TotalTokens > 0 → 按 token 重算
|
||||
// 3. 都不满足 → 保持预扣额度不变
|
||||
func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo) {
|
||||
// 0. 按次计费的任务不做差额结算
|
||||
if bc := task.PrivateData.BillingContext; bc != nil && bc.PerCallBilling {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 按次计费,跳过差额结算", task.TaskID))
|
||||
return
|
||||
}
|
||||
// 1. 优先让 adaptor 决定最终额度
|
||||
if actualQuota := adaptor.AdjustBillingOnComplete(task, taskResult); actualQuota > 0 {
|
||||
RecalculateTaskQuota(ctx, task, actualQuota, "adaptor计费调整")
|
||||
return
|
||||
}
|
||||
// 2. 回退到 token 重算
|
||||
if taskResult.TotalTokens > 0 {
|
||||
RecalculateTaskQuotaByTokens(ctx, task, taskResult.TotalTokens)
|
||||
return
|
||||
}
|
||||
// 3. 无调整,保持预扣额度
|
||||
}
|
||||
427
service/text_quota.go
Normal file
427
service/text_quota.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type textQuotaSummary struct {
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
CacheTokens int
|
||||
CacheCreationTokens int
|
||||
CacheCreationTokens5m int
|
||||
CacheCreationTokens1h int
|
||||
ImageTokens int
|
||||
AudioTokens int
|
||||
ModelName string
|
||||
TokenName string
|
||||
UseTimeSeconds int64
|
||||
CompletionRatio float64
|
||||
CacheRatio float64
|
||||
ImageRatio float64
|
||||
ModelRatio float64
|
||||
GroupRatio float64
|
||||
ModelPrice float64
|
||||
CacheCreationRatio float64
|
||||
CacheCreationRatio5m float64
|
||||
CacheCreationRatio1h float64
|
||||
Quota int
|
||||
IsClaudeUsageSemantic bool
|
||||
UsageSemantic string
|
||||
WebSearchPrice float64
|
||||
WebSearchCallCount int
|
||||
ClaudeWebSearchPrice float64
|
||||
ClaudeWebSearchCallCount int
|
||||
FileSearchPrice float64
|
||||
FileSearchCallCount int
|
||||
AudioInputPrice float64
|
||||
ImageGenerationCallPrice float64
|
||||
}
|
||||
|
||||
func cacheWriteTokensTotal(summary textQuotaSummary) int {
|
||||
if summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0 {
|
||||
splitCacheWriteTokens := summary.CacheCreationTokens5m + summary.CacheCreationTokens1h
|
||||
if summary.CacheCreationTokens > splitCacheWriteTokens {
|
||||
return summary.CacheCreationTokens
|
||||
}
|
||||
return splitCacheWriteTokens
|
||||
}
|
||||
return summary.CacheCreationTokens
|
||||
}
|
||||
|
||||
func isLegacyClaudeDerivedOpenAIUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) bool {
|
||||
if relayInfo == nil || usage == nil {
|
||||
return false
|
||||
}
|
||||
if relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
|
||||
return false
|
||||
}
|
||||
if usage.UsageSource != "" || usage.UsageSemantic != "" {
|
||||
return false
|
||||
}
|
||||
return usage.ClaudeCacheCreation5mTokens > 0 || usage.ClaudeCacheCreation1hTokens > 0
|
||||
}
|
||||
|
||||
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
|
||||
summary := textQuotaSummary{
|
||||
ModelName: relayInfo.OriginModelName,
|
||||
TokenName: ctx.GetString("token_name"),
|
||||
UseTimeSeconds: time.Now().Unix() - relayInfo.StartTime.Unix(),
|
||||
CompletionRatio: relayInfo.PriceData.CompletionRatio,
|
||||
CacheRatio: relayInfo.PriceData.CacheRatio,
|
||||
ImageRatio: relayInfo.PriceData.ImageRatio,
|
||||
ModelRatio: relayInfo.PriceData.ModelRatio,
|
||||
GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
|
||||
ModelPrice: relayInfo.PriceData.ModelPrice,
|
||||
CacheCreationRatio: relayInfo.PriceData.CacheCreationRatio,
|
||||
CacheCreationRatio5m: relayInfo.PriceData.CacheCreation5mRatio,
|
||||
CacheCreationRatio1h: relayInfo.PriceData.CacheCreation1hRatio,
|
||||
UsageSemantic: usageSemanticFromUsage(relayInfo, usage),
|
||||
}
|
||||
summary.IsClaudeUsageSemantic = summary.UsageSemantic == "anthropic"
|
||||
|
||||
if usage == nil {
|
||||
usage = &dto.Usage{
|
||||
PromptTokens: relayInfo.GetEstimatePromptTokens(),
|
||||
CompletionTokens: 0,
|
||||
TotalTokens: relayInfo.GetEstimatePromptTokens(),
|
||||
}
|
||||
}
|
||||
|
||||
summary.PromptTokens = usage.PromptTokens
|
||||
summary.CompletionTokens = usage.CompletionTokens
|
||||
summary.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
summary.CacheTokens = usage.PromptTokensDetails.CachedTokens
|
||||
summary.CacheCreationTokens = usage.PromptTokensDetails.CachedCreationTokens
|
||||
summary.CacheCreationTokens5m = usage.ClaudeCacheCreation5mTokens
|
||||
summary.CacheCreationTokens1h = usage.ClaudeCacheCreation1hTokens
|
||||
summary.ImageTokens = usage.PromptTokensDetails.ImageTokens
|
||||
summary.AudioTokens = usage.PromptTokensDetails.AudioTokens
|
||||
legacyClaudeDerived := isLegacyClaudeDerivedOpenAIUsage(relayInfo, usage)
|
||||
|
||||
if relayInfo.ChannelMeta != nil && relayInfo.ChannelType == constant.ChannelTypeOpenRouter {
|
||||
summary.PromptTokens -= summary.CacheTokens
|
||||
isUsingCustomSettings := relayInfo.PriceData.UsePrice || hasCustomModelRatio(summary.ModelName, relayInfo.PriceData.ModelRatio)
|
||||
if summary.CacheCreationTokens == 0 && relayInfo.PriceData.CacheCreationRatio != 1 && usage.Cost != 0 && !isUsingCustomSettings {
|
||||
maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, relayInfo.PriceData)
|
||||
if maybeCacheCreationTokens >= 0 && summary.PromptTokens >= maybeCacheCreationTokens {
|
||||
summary.CacheCreationTokens = maybeCacheCreationTokens
|
||||
}
|
||||
}
|
||||
summary.PromptTokens -= summary.CacheCreationTokens
|
||||
}
|
||||
|
||||
dPromptTokens := decimal.NewFromInt(int64(summary.PromptTokens))
|
||||
dCacheTokens := decimal.NewFromInt(int64(summary.CacheTokens))
|
||||
dImageTokens := decimal.NewFromInt(int64(summary.ImageTokens))
|
||||
dAudioTokens := decimal.NewFromInt(int64(summary.AudioTokens))
|
||||
dCompletionTokens := decimal.NewFromInt(int64(summary.CompletionTokens))
|
||||
dCachedCreationTokens := decimal.NewFromInt(int64(summary.CacheCreationTokens))
|
||||
dCompletionRatio := decimal.NewFromFloat(summary.CompletionRatio)
|
||||
dCacheRatio := decimal.NewFromFloat(summary.CacheRatio)
|
||||
dImageRatio := decimal.NewFromFloat(summary.ImageRatio)
|
||||
dModelRatio := decimal.NewFromFloat(summary.ModelRatio)
|
||||
dGroupRatio := decimal.NewFromFloat(summary.GroupRatio)
|
||||
dModelPrice := decimal.NewFromFloat(summary.ModelPrice)
|
||||
dCacheCreationRatio := decimal.NewFromFloat(summary.CacheCreationRatio)
|
||||
dCacheCreationRatio5m := decimal.NewFromFloat(summary.CacheCreationRatio5m)
|
||||
dCacheCreationRatio1h := decimal.NewFromFloat(summary.CacheCreationRatio1h)
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
|
||||
ratio := dModelRatio.Mul(dGroupRatio)
|
||||
|
||||
var dWebSearchQuota decimal.Decimal
|
||||
if relayInfo.ResponsesUsageInfo != nil {
|
||||
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool.CallCount > 0 {
|
||||
summary.WebSearchCallCount = webSearchTool.CallCount
|
||||
summary.WebSearchPrice = operation_setting.GetWebSearchPricePerThousand(summary.ModelName, webSearchTool.SearchContextSize)
|
||||
dWebSearchQuota = decimal.NewFromFloat(summary.WebSearchPrice).
|
||||
Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))).
|
||||
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
}
|
||||
} else if strings.HasSuffix(summary.ModelName, "search-preview") {
|
||||
searchContextSize := ctx.GetString("chat_completion_web_search_context_size")
|
||||
if searchContextSize == "" {
|
||||
searchContextSize = "medium"
|
||||
}
|
||||
summary.WebSearchCallCount = 1
|
||||
summary.WebSearchPrice = operation_setting.GetWebSearchPricePerThousand(summary.ModelName, searchContextSize)
|
||||
dWebSearchQuota = decimal.NewFromFloat(summary.WebSearchPrice).
|
||||
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
}
|
||||
|
||||
var dClaudeWebSearchQuota decimal.Decimal
|
||||
summary.ClaudeWebSearchCallCount = ctx.GetInt("claude_web_search_requests")
|
||||
if summary.ClaudeWebSearchCallCount > 0 {
|
||||
summary.ClaudeWebSearchPrice = operation_setting.GetClaudeWebSearchPricePerThousand()
|
||||
dClaudeWebSearchQuota = decimal.NewFromFloat(summary.ClaudeWebSearchPrice).
|
||||
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit).
|
||||
Mul(decimal.NewFromInt(int64(summary.ClaudeWebSearchCallCount)))
|
||||
}
|
||||
|
||||
var dFileSearchQuota decimal.Decimal
|
||||
if relayInfo.ResponsesUsageInfo != nil {
|
||||
if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists && fileSearchTool.CallCount > 0 {
|
||||
summary.FileSearchCallCount = fileSearchTool.CallCount
|
||||
summary.FileSearchPrice = operation_setting.GetFileSearchPricePerThousand()
|
||||
dFileSearchQuota = decimal.NewFromFloat(summary.FileSearchPrice).
|
||||
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
|
||||
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
}
|
||||
}
|
||||
|
||||
var dImageGenerationCallQuota decimal.Decimal
|
||||
if ctx.GetBool("image_generation_call") {
|
||||
summary.ImageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
|
||||
dImageGenerationCallQuota = decimal.NewFromFloat(summary.ImageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
}
|
||||
|
||||
var audioInputQuota decimal.Decimal
|
||||
if !relayInfo.PriceData.UsePrice {
|
||||
baseTokens := dPromptTokens
|
||||
|
||||
var cachedTokensWithRatio decimal.Decimal
|
||||
if !dCacheTokens.IsZero() {
|
||||
if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived {
|
||||
baseTokens = baseTokens.Sub(dCacheTokens)
|
||||
}
|
||||
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
|
||||
}
|
||||
|
||||
var cachedCreationTokensWithRatio decimal.Decimal
|
||||
hasSplitCacheCreationTokens := summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0
|
||||
if !dCachedCreationTokens.IsZero() || hasSplitCacheCreationTokens {
|
||||
if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived {
|
||||
baseTokens = baseTokens.Sub(dCachedCreationTokens)
|
||||
cachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCacheCreationRatio)
|
||||
} else {
|
||||
remaining := summary.CacheCreationTokens - summary.CacheCreationTokens5m - summary.CacheCreationTokens1h
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
cachedCreationTokensWithRatio = decimal.NewFromInt(int64(remaining)).Mul(dCacheCreationRatio)
|
||||
cachedCreationTokensWithRatio = cachedCreationTokensWithRatio.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens5m)).Mul(dCacheCreationRatio5m))
|
||||
cachedCreationTokensWithRatio = cachedCreationTokensWithRatio.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens1h)).Mul(dCacheCreationRatio1h))
|
||||
}
|
||||
}
|
||||
|
||||
var imageTokensWithRatio decimal.Decimal
|
||||
if !dImageTokens.IsZero() {
|
||||
baseTokens = baseTokens.Sub(dImageTokens)
|
||||
imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
|
||||
}
|
||||
|
||||
if !dAudioTokens.IsZero() {
|
||||
summary.AudioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(summary.ModelName)
|
||||
if summary.AudioInputPrice > 0 {
|
||||
baseTokens = baseTokens.Sub(dAudioTokens)
|
||||
audioInputQuota = decimal.NewFromFloat(summary.AudioInputPrice).
|
||||
Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
}
|
||||
}
|
||||
|
||||
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio)
|
||||
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
|
||||
quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dClaudeWebSearchQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
|
||||
|
||||
if len(relayInfo.PriceData.OtherRatios) > 0 {
|
||||
for _, otherRatio := range relayInfo.PriceData.OtherRatios {
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio))
|
||||
}
|
||||
}
|
||||
|
||||
if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) {
|
||||
quotaCalculateDecimal = decimal.NewFromInt(1)
|
||||
}
|
||||
summary.Quota = int(quotaCalculateDecimal.Round(0).IntPart())
|
||||
} else {
|
||||
quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dClaudeWebSearchQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
|
||||
if len(relayInfo.PriceData.OtherRatios) > 0 {
|
||||
for _, otherRatio := range relayInfo.PriceData.OtherRatios {
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio))
|
||||
}
|
||||
}
|
||||
summary.Quota = int(quotaCalculateDecimal.Round(0).IntPart())
|
||||
}
|
||||
|
||||
if summary.TotalTokens == 0 {
|
||||
summary.Quota = 0
|
||||
} else if !ratio.IsZero() && summary.Quota == 0 {
|
||||
summary.Quota = 1
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) string {
|
||||
if usage != nil && usage.UsageSemantic != "" {
|
||||
return usage.UsageSemantic
|
||||
}
|
||||
if relayInfo != nil && relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
|
||||
return "anthropic"
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) {
|
||||
originUsage := usage
|
||||
if usage == nil {
|
||||
extraContent = append(extraContent, "上游无计费信息")
|
||||
}
|
||||
if originUsage != nil {
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
|
||||
if summary.WebSearchCallCount > 0 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("Web Search 调用 %d 次,调用花费 %s", summary.WebSearchCallCount, decimal.NewFromFloat(summary.WebSearchPrice).Mul(decimal.NewFromInt(int64(summary.WebSearchCallCount))).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
|
||||
}
|
||||
if summary.ClaudeWebSearchCallCount > 0 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("Claude Web Search 调用 %d 次,调用花费 %s", summary.ClaudeWebSearchCallCount, decimal.NewFromFloat(summary.ClaudeWebSearchPrice).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).Mul(decimal.NewFromInt(int64(summary.ClaudeWebSearchCallCount))).String()))
|
||||
}
|
||||
if summary.FileSearchCallCount > 0 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("File Search 调用 %d 次,调用花费 %s", summary.FileSearchCallCount, decimal.NewFromFloat(summary.FileSearchPrice).Mul(decimal.NewFromInt(int64(summary.FileSearchCallCount))).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
|
||||
}
|
||||
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("Audio Input 花费 %s", decimal.NewFromFloat(summary.AudioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(decimal.NewFromInt(int64(summary.AudioTokens))).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
|
||||
}
|
||||
if summary.ImageGenerationCallPrice > 0 {
|
||||
extraContent = append(extraContent, fmt.Sprintf("Image Generation Call 花费 %s", decimal.NewFromFloat(summary.ImageGenerationCallPrice).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
|
||||
}
|
||||
|
||||
if summary.TotalTokens == 0 {
|
||||
extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)")
|
||||
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, summary.ModelName, relayInfo.FinalPreConsumedQuota))
|
||||
} else {
|
||||
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, summary.Quota)
|
||||
model.UpdateChannelUsedQuota(relayInfo.ChannelId, summary.Quota)
|
||||
}
|
||||
|
||||
if err := SettleBilling(ctx, relayInfo, summary.Quota); err != nil {
|
||||
logger.LogError(ctx, "error settling billing: "+err.Error())
|
||||
}
|
||||
|
||||
logModel := summary.ModelName
|
||||
if strings.HasPrefix(logModel, "gpt-4-gizmo") {
|
||||
logModel = "gpt-4-gizmo-*"
|
||||
extraContent = append(extraContent, fmt.Sprintf("模型 %s", summary.ModelName))
|
||||
}
|
||||
if strings.HasPrefix(logModel, "gpt-4o-gizmo") {
|
||||
logModel = "gpt-4o-gizmo-*"
|
||||
extraContent = append(extraContent, fmt.Sprintf("模型 %s", summary.ModelName))
|
||||
}
|
||||
|
||||
logContent := strings.Join(extraContent, ", ")
|
||||
var other map[string]interface{}
|
||||
if summary.IsClaudeUsageSemantic {
|
||||
other = GenerateClaudeOtherInfo(ctx, relayInfo,
|
||||
summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio,
|
||||
summary.CacheTokens, summary.CacheRatio,
|
||||
summary.CacheCreationTokens, summary.CacheCreationRatio,
|
||||
summary.CacheCreationTokens5m, summary.CacheCreationRatio5m,
|
||||
summary.CacheCreationTokens1h, summary.CacheCreationRatio1h,
|
||||
summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
||||
other["usage_semantic"] = "anthropic"
|
||||
} else {
|
||||
other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
||||
}
|
||||
if adminRejectReason != "" {
|
||||
other["reject_reason"] = adminRejectReason
|
||||
}
|
||||
if summary.ImageTokens != 0 {
|
||||
other["image"] = true
|
||||
other["image_ratio"] = summary.ImageRatio
|
||||
other["image_output"] = summary.ImageTokens
|
||||
}
|
||||
if summary.WebSearchCallCount > 0 {
|
||||
other["web_search"] = true
|
||||
other["web_search_call_count"] = summary.WebSearchCallCount
|
||||
other["web_search_price"] = summary.WebSearchPrice
|
||||
} else if summary.ClaudeWebSearchCallCount > 0 {
|
||||
other["web_search"] = true
|
||||
other["web_search_call_count"] = summary.ClaudeWebSearchCallCount
|
||||
other["web_search_price"] = summary.ClaudeWebSearchPrice
|
||||
}
|
||||
if summary.FileSearchCallCount > 0 {
|
||||
other["file_search"] = true
|
||||
other["file_search_call_count"] = summary.FileSearchCallCount
|
||||
other["file_search_price"] = summary.FileSearchPrice
|
||||
}
|
||||
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
|
||||
other["audio_input_seperate_price"] = true
|
||||
other["audio_input_token_count"] = summary.AudioTokens
|
||||
other["audio_input_price"] = summary.AudioInputPrice
|
||||
}
|
||||
if summary.ImageGenerationCallPrice > 0 {
|
||||
other["image_generation_call"] = true
|
||||
other["image_generation_call_price"] = summary.ImageGenerationCallPrice
|
||||
}
|
||||
if summary.CacheCreationTokens > 0 {
|
||||
other["cache_creation_tokens"] = summary.CacheCreationTokens
|
||||
other["cache_creation_ratio"] = summary.CacheCreationRatio
|
||||
}
|
||||
if summary.CacheCreationTokens5m > 0 {
|
||||
other["cache_creation_tokens_5m"] = summary.CacheCreationTokens5m
|
||||
other["cache_creation_ratio_5m"] = summary.CacheCreationRatio5m
|
||||
}
|
||||
if summary.CacheCreationTokens1h > 0 {
|
||||
other["cache_creation_tokens_1h"] = summary.CacheCreationTokens1h
|
||||
other["cache_creation_ratio_1h"] = summary.CacheCreationRatio1h
|
||||
}
|
||||
cacheWriteTokens := cacheWriteTokensTotal(summary)
|
||||
if cacheWriteTokens > 0 {
|
||||
// cache_write_tokens: normalized cache creation total for UI display.
|
||||
// If split 5m/1h values are present, this is their sum; otherwise it falls back
|
||||
// to cache_creation_tokens.
|
||||
other["cache_write_tokens"] = cacheWriteTokens
|
||||
}
|
||||
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && usage != nil && usage.UsageSource != "" && usage.InputTokens > 0 {
|
||||
// input_tokens_total: explicit normalized total input used by the usage log UI.
|
||||
// Only write this field when upstream/current conversion has already provided a
|
||||
// reliable total input value and tagged the usage source. Do not infer it from
|
||||
// prompt/cache fields here, otherwise old upstream payloads may be double-counted.
|
||||
other["input_tokens_total"] = usage.InputTokens
|
||||
}
|
||||
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||
ChannelId: relayInfo.ChannelId,
|
||||
PromptTokens: summary.PromptTokens,
|
||||
CompletionTokens: summary.CompletionTokens,
|
||||
ModelName: logModel,
|
||||
TokenName: summary.TokenName,
|
||||
Quota: summary.Quota,
|
||||
Content: logContent,
|
||||
TokenId: relayInfo.TokenId,
|
||||
UseTimeSeconds: int(summary.UseTimeSeconds),
|
||||
IsStream: relayInfo.IsStream,
|
||||
Group: relayInfo.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
}
|
||||
206
service/text_quota_test.go
Normal file
206
service/text_quota_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCalculateTextQuotaSummaryUnifiedForClaudeSemantic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 1000,
|
||||
CompletionTokens: 200,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 100,
|
||||
CachedCreationTokens: 50,
|
||||
},
|
||||
ClaudeCacheCreation5mTokens: 10,
|
||||
ClaudeCacheCreation1hTokens: 20,
|
||||
}
|
||||
|
||||
priceData := types.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 2,
|
||||
CacheRatio: 0.1,
|
||||
CacheCreationRatio: 1.25,
|
||||
CacheCreation5mRatio: 1.25,
|
||||
CacheCreation1hRatio: 2,
|
||||
GroupRatioInfo: types.GroupRatioInfo{
|
||||
GroupRatio: 1,
|
||||
},
|
||||
}
|
||||
|
||||
chatRelayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
FinalRequestRelayFormat: types.RelayFormatClaude,
|
||||
OriginModelName: "claude-3-7-sonnet",
|
||||
PriceData: priceData,
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
messageRelayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatClaude,
|
||||
FinalRequestRelayFormat: types.RelayFormatClaude,
|
||||
OriginModelName: "claude-3-7-sonnet",
|
||||
PriceData: priceData,
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
chatSummary := calculateTextQuotaSummary(ctx, chatRelayInfo, usage)
|
||||
messageSummary := calculateTextQuotaSummary(ctx, messageRelayInfo, usage)
|
||||
|
||||
require.Equal(t, messageSummary.Quota, chatSummary.Quota)
|
||||
require.Equal(t, messageSummary.CacheCreationTokens5m, chatSummary.CacheCreationTokens5m)
|
||||
require.Equal(t, messageSummary.CacheCreationTokens1h, chatSummary.CacheCreationTokens1h)
|
||||
require.True(t, chatSummary.IsClaudeUsageSemantic)
|
||||
require.Equal(t, 1488, chatSummary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryUsesSplitClaudeCacheCreationRatios(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
FinalRequestRelayFormat: types.RelayFormatClaude,
|
||||
OriginModelName: "claude-3-7-sonnet",
|
||||
PriceData: types.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 1,
|
||||
CacheRatio: 0,
|
||||
CacheCreationRatio: 1,
|
||||
CacheCreation5mRatio: 2,
|
||||
CacheCreation1hRatio: 3,
|
||||
GroupRatioInfo: types.GroupRatioInfo{
|
||||
GroupRatio: 1,
|
||||
},
|
||||
},
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 0,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedCreationTokens: 10,
|
||||
},
|
||||
ClaudeCacheCreation5mTokens: 2,
|
||||
ClaudeCacheCreation1hTokens: 3,
|
||||
}
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
|
||||
// 100 + remaining(5)*1 + 2*2 + 3*3 = 118
|
||||
require.Equal(t, 118, summary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryUsesAnthropicUsageSemanticFromUpstreamUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
OriginModelName: "claude-3-7-sonnet",
|
||||
PriceData: types.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 2,
|
||||
CacheRatio: 0.1,
|
||||
CacheCreationRatio: 1.25,
|
||||
CacheCreation5mRatio: 1.25,
|
||||
CacheCreation1hRatio: 2,
|
||||
GroupRatioInfo: types.GroupRatioInfo{
|
||||
GroupRatio: 1,
|
||||
},
|
||||
},
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 1000,
|
||||
CompletionTokens: 200,
|
||||
UsageSemantic: "anthropic",
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 100,
|
||||
CachedCreationTokens: 50,
|
||||
},
|
||||
ClaudeCacheCreation5mTokens: 10,
|
||||
ClaudeCacheCreation1hTokens: 20,
|
||||
}
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
|
||||
require.True(t, summary.IsClaudeUsageSemantic)
|
||||
require.Equal(t, "anthropic", summary.UsageSemantic)
|
||||
require.Equal(t, 1488, summary.Quota)
|
||||
}
|
||||
|
||||
func TestCacheWriteTokensTotal(t *testing.T) {
|
||||
t.Run("split cache creation", func(t *testing.T) {
|
||||
summary := textQuotaSummary{
|
||||
CacheCreationTokens: 50,
|
||||
CacheCreationTokens5m: 10,
|
||||
CacheCreationTokens1h: 20,
|
||||
}
|
||||
require.Equal(t, 50, cacheWriteTokensTotal(summary))
|
||||
})
|
||||
|
||||
t.Run("legacy cache creation", func(t *testing.T) {
|
||||
summary := textQuotaSummary{CacheCreationTokens: 50}
|
||||
require.Equal(t, 50, cacheWriteTokensTotal(summary))
|
||||
})
|
||||
|
||||
t.Run("split cache creation without aggregate remainder", func(t *testing.T) {
|
||||
summary := textQuotaSummary{
|
||||
CacheCreationTokens5m: 10,
|
||||
CacheCreationTokens1h: 20,
|
||||
}
|
||||
require.Equal(t, 30, cacheWriteTokensTotal(summary))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryHandlesLegacyClaudeDerivedOpenAIUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
OriginModelName: "claude-3-7-sonnet",
|
||||
PriceData: types.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 5,
|
||||
CacheRatio: 0.1,
|
||||
CacheCreationRatio: 1.25,
|
||||
CacheCreation5mRatio: 1.25,
|
||||
CacheCreation1hRatio: 2,
|
||||
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||
},
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 62,
|
||||
CompletionTokens: 95,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 3544,
|
||||
},
|
||||
ClaudeCacheCreation5mTokens: 586,
|
||||
}
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
|
||||
// 62 + 3544*0.1 + 586*1.25 + 95*5 = 1624.9 => 1624
|
||||
require.Equal(t, 1624, summary.Quota)
|
||||
}
|
||||
411
service/token_counter.go
Normal file
411
service/token_counter.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
constant2 "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, stream bool) (int, error) {
|
||||
if fileMeta == nil || fileMeta.Source == nil {
|
||||
return 0, fmt.Errorf("image_url_is_nil")
|
||||
}
|
||||
|
||||
// Defaults for 4o/4.1/4.5 family unless overridden below
|
||||
baseTokens := 85
|
||||
tileTokens := 170
|
||||
|
||||
// Model classification
|
||||
lowerModel := strings.ToLower(model)
|
||||
|
||||
// Special cases from existing behavior
|
||||
if strings.HasPrefix(lowerModel, "glm-4") {
|
||||
return 1047, nil
|
||||
}
|
||||
|
||||
// Patch-based models (32x32 patches, capped at 1536, with multiplier)
|
||||
isPatchBased := false
|
||||
multiplier := 1.0
|
||||
switch {
|
||||
case strings.Contains(lowerModel, "gpt-4.1-mini"):
|
||||
isPatchBased = true
|
||||
multiplier = 1.62
|
||||
case strings.Contains(lowerModel, "gpt-4.1-nano"):
|
||||
isPatchBased = true
|
||||
multiplier = 2.46
|
||||
case strings.HasPrefix(lowerModel, "o4-mini"):
|
||||
isPatchBased = true
|
||||
multiplier = 1.72
|
||||
case strings.HasPrefix(lowerModel, "gpt-5-mini"):
|
||||
isPatchBased = true
|
||||
multiplier = 1.62
|
||||
case strings.HasPrefix(lowerModel, "gpt-5-nano"):
|
||||
isPatchBased = true
|
||||
multiplier = 2.46
|
||||
}
|
||||
|
||||
// Tile-based model tokens and bases per doc
|
||||
if !isPatchBased {
|
||||
if strings.HasPrefix(lowerModel, "gpt-4o-mini") {
|
||||
baseTokens = 2833
|
||||
tileTokens = 5667
|
||||
} else if strings.HasPrefix(lowerModel, "gpt-5-chat-latest") || (strings.HasPrefix(lowerModel, "gpt-5") && !strings.Contains(lowerModel, "mini") && !strings.Contains(lowerModel, "nano")) {
|
||||
baseTokens = 70
|
||||
tileTokens = 140
|
||||
} else if strings.HasPrefix(lowerModel, "o1") || strings.HasPrefix(lowerModel, "o3") || strings.HasPrefix(lowerModel, "o1-pro") {
|
||||
baseTokens = 75
|
||||
tileTokens = 150
|
||||
} else if strings.Contains(lowerModel, "computer-use-preview") {
|
||||
baseTokens = 65
|
||||
tileTokens = 129
|
||||
} else if strings.Contains(lowerModel, "4.1") || strings.Contains(lowerModel, "4o") || strings.Contains(lowerModel, "4.5") {
|
||||
baseTokens = 85
|
||||
tileTokens = 170
|
||||
}
|
||||
}
|
||||
|
||||
// Respect existing feature flags/short-circuits
|
||||
if fileMeta.Detail == "low" && !isPatchBased {
|
||||
return baseTokens, nil
|
||||
}
|
||||
|
||||
// Whether to count image tokens at all
|
||||
if !constant.GetMediaToken {
|
||||
return 3 * baseTokens, nil
|
||||
}
|
||||
|
||||
if !constant.GetMediaTokenNotStream && !stream {
|
||||
return 3 * baseTokens, nil
|
||||
}
|
||||
// Normalize detail
|
||||
if fileMeta.Detail == "auto" || fileMeta.Detail == "" {
|
||||
fileMeta.Detail = "high"
|
||||
}
|
||||
|
||||
// 使用统一的文件服务获取图片配置
|
||||
config, format, err := GetImageConfig(c, fileMeta.Source)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fileMeta.MimeType = format
|
||||
|
||||
if config.Width == 0 || config.Height == 0 {
|
||||
// not an image, but might be a valid file
|
||||
if format != "" {
|
||||
// file type
|
||||
return 3 * baseTokens, nil
|
||||
}
|
||||
return 0, errors.New(fmt.Sprintf("fail to decode image config: %s", fileMeta.GetIdentifier()))
|
||||
}
|
||||
|
||||
width := config.Width
|
||||
height := config.Height
|
||||
log.Printf("format: %s, width: %d, height: %d", format, width, height)
|
||||
|
||||
if isPatchBased {
|
||||
// 32x32 patch-based calculation with 1536 cap and model multiplier
|
||||
ceilDiv := func(a, b int) int { return (a + b - 1) / b }
|
||||
rawPatchesW := ceilDiv(width, 32)
|
||||
rawPatchesH := ceilDiv(height, 32)
|
||||
rawPatches := rawPatchesW * rawPatchesH
|
||||
if rawPatches > 1536 {
|
||||
// scale down
|
||||
area := float64(width * height)
|
||||
r := math.Sqrt(float64(32*32*1536) / area)
|
||||
wScaled := float64(width) * r
|
||||
hScaled := float64(height) * r
|
||||
// adjust to fit whole number of patches after scaling
|
||||
adjW := math.Floor(wScaled/32.0) / (wScaled / 32.0)
|
||||
adjH := math.Floor(hScaled/32.0) / (hScaled / 32.0)
|
||||
adj := math.Min(adjW, adjH)
|
||||
if !math.IsNaN(adj) && adj > 0 {
|
||||
r = r * adj
|
||||
}
|
||||
wScaled = float64(width) * r
|
||||
hScaled = float64(height) * r
|
||||
patchesW := math.Ceil(wScaled / 32.0)
|
||||
patchesH := math.Ceil(hScaled / 32.0)
|
||||
imageTokens := int(patchesW * patchesH)
|
||||
if imageTokens > 1536 {
|
||||
imageTokens = 1536
|
||||
}
|
||||
return int(math.Round(float64(imageTokens) * multiplier)), nil
|
||||
}
|
||||
// below cap
|
||||
imageTokens := rawPatches
|
||||
return int(math.Round(float64(imageTokens) * multiplier)), nil
|
||||
}
|
||||
|
||||
// Tile-based calculation for 4o/4.1/4.5/o1/o3/etc.
|
||||
// Step 1: fit within 2048x2048 square
|
||||
maxSide := math.Max(float64(width), float64(height))
|
||||
fitScale := 1.0
|
||||
if maxSide > 2048 {
|
||||
fitScale = maxSide / 2048.0
|
||||
}
|
||||
fitW := int(math.Round(float64(width) / fitScale))
|
||||
fitH := int(math.Round(float64(height) / fitScale))
|
||||
|
||||
// Step 2: scale so that shortest side is exactly 768
|
||||
minSide := math.Min(float64(fitW), float64(fitH))
|
||||
if minSide == 0 {
|
||||
return baseTokens, nil
|
||||
}
|
||||
shortScale := 768.0 / minSide
|
||||
finalW := int(math.Round(float64(fitW) * shortScale))
|
||||
finalH := int(math.Round(float64(fitH) * shortScale))
|
||||
|
||||
// Count 512px tiles
|
||||
tilesW := (finalW + 512 - 1) / 512
|
||||
tilesH := (finalH + 512 - 1) / 512
|
||||
tiles := tilesW * tilesH
|
||||
|
||||
if common.DebugEnabled {
|
||||
log.Printf("scaled to: %dx%d, tiles: %d", finalW, finalH, tiles)
|
||||
}
|
||||
|
||||
return tiles*tileTokens + baseTokens, nil
|
||||
}
|
||||
|
||||
func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) {
|
||||
// 是否统计token
|
||||
if !constant.CountToken {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if meta == nil {
|
||||
return 0, errors.New("token count meta is nil")
|
||||
}
|
||||
|
||||
if info.RelayFormat == types.RelayFormatOpenAIRealtime {
|
||||
return 0, nil
|
||||
}
|
||||
if info.RelayMode == constant2.RelayModeAudioTranscription || info.RelayMode == constant2.RelayModeAudioTranslation {
|
||||
multiForm, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error parsing multipart form: %v", err)
|
||||
}
|
||||
fileHeaders := multiForm.File["file"]
|
||||
totalAudioToken := 0
|
||||
for _, fileHeader := range fileHeaders {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error opening audio file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
// get ext and io.seeker
|
||||
ext := filepath.Ext(fileHeader.Filename)
|
||||
duration, err := common.GetAudioDuration(c.Request.Context(), file, ext)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error getting audio duration: %v", err)
|
||||
}
|
||||
// 一分钟 1000 token,与 $price / minute 对齐
|
||||
totalAudioToken += int(math.Round(math.Ceil(duration) / 60.0 * 1000))
|
||||
}
|
||||
return totalAudioToken, nil
|
||||
}
|
||||
|
||||
model := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
|
||||
tkm := 0
|
||||
|
||||
if meta.TokenType == types.TokenTypeTextNumber {
|
||||
tkm += utf8.RuneCountInString(meta.CombineText)
|
||||
} else {
|
||||
tkm += CountTextToken(meta.CombineText, model)
|
||||
}
|
||||
|
||||
if info.RelayFormat == types.RelayFormatOpenAI {
|
||||
tkm += meta.ToolsCount * 8
|
||||
tkm += meta.MessagesCount * 3 // 每条消息的格式化token数量
|
||||
tkm += meta.NameCount * 3
|
||||
tkm += 3
|
||||
}
|
||||
|
||||
shouldFetchFiles := true
|
||||
|
||||
if info.RelayFormat == types.RelayFormatGemini {
|
||||
shouldFetchFiles = false
|
||||
}
|
||||
|
||||
// 是否本地计算媒体token数量
|
||||
if !constant.GetMediaToken {
|
||||
shouldFetchFiles = false
|
||||
}
|
||||
|
||||
// 是否在非流模式下本地计算媒体token数量
|
||||
if !constant.GetMediaTokenNotStream && !info.IsStream {
|
||||
shouldFetchFiles = false
|
||||
}
|
||||
|
||||
// 使用统一的文件服务获取文件类型
|
||||
for _, file := range meta.Files {
|
||||
if file.Source == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果文件类型未知且需要获取,通过 MIME 类型检测
|
||||
if file.FileType == "" || (file.Source.IsURL() && shouldFetchFiles) {
|
||||
// 注意:这里我们直接调用 LoadFileSource 而不是 GetMimeType
|
||||
// 因为 GetMimeType 内部可能会调用 GetFileTypeFromUrl (HEAD 请求)
|
||||
// 而我们这里既然要计算 token,通常需要完整数据
|
||||
cachedData, err := LoadFileSource(c, file.Source, "token_counter")
|
||||
if err != nil {
|
||||
if shouldFetchFiles {
|
||||
return 0, fmt.Errorf("error getting file type: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
file.MimeType = cachedData.MimeType
|
||||
file.FileType = DetectFileType(cachedData.MimeType)
|
||||
}
|
||||
}
|
||||
|
||||
for i, file := range meta.Files {
|
||||
switch file.FileType {
|
||||
case types.FileTypeImage:
|
||||
if common.IsOpenAITextModel(model) {
|
||||
token, err := getImageToken(c, file, model, info.IsStream)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error counting image token, media index[%d], identifier[%s], err: %v", i, file.GetIdentifier(), err)
|
||||
}
|
||||
tkm += token
|
||||
} else {
|
||||
tkm += 520
|
||||
}
|
||||
case types.FileTypeAudio:
|
||||
tkm += 256
|
||||
case types.FileTypeVideo:
|
||||
tkm += 4096 * 2
|
||||
case types.FileTypeFile:
|
||||
tkm += 4096
|
||||
default:
|
||||
tkm += 4096 // Default case for unknown file types
|
||||
}
|
||||
}
|
||||
|
||||
common.SetContextKey(c, constant.ContextKeyPromptTokens, tkm)
|
||||
return tkm, nil
|
||||
}
|
||||
|
||||
func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, model string) (int, int, error) {
|
||||
audioToken := 0
|
||||
textToken := 0
|
||||
switch request.Type {
|
||||
case dto.RealtimeEventTypeSessionUpdate:
|
||||
if request.Session != nil {
|
||||
msgTokens := CountTextToken(request.Session.Instructions, model)
|
||||
textToken += msgTokens
|
||||
}
|
||||
case dto.RealtimeEventResponseAudioDelta:
|
||||
// count audio token
|
||||
atk, err := CountAudioTokenOutput(request.Delta, info.OutputAudioFormat)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("error counting audio token: %v", err)
|
||||
}
|
||||
audioToken += atk
|
||||
case dto.RealtimeEventResponseAudioTranscriptionDelta, dto.RealtimeEventResponseFunctionCallArgumentsDelta:
|
||||
// count text token
|
||||
tkm := CountTextToken(request.Delta, model)
|
||||
textToken += tkm
|
||||
case dto.RealtimeEventInputAudioBufferAppend:
|
||||
// count audio token
|
||||
atk, err := CountAudioTokenInput(request.Audio, info.InputAudioFormat)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("error counting audio token: %v", err)
|
||||
}
|
||||
audioToken += atk
|
||||
case dto.RealtimeEventConversationItemCreated:
|
||||
if request.Item != nil {
|
||||
switch request.Item.Type {
|
||||
case "message":
|
||||
for _, content := range request.Item.Content {
|
||||
if content.Type == "input_text" {
|
||||
tokens := CountTextToken(content.Text, model)
|
||||
textToken += tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case dto.RealtimeEventTypeResponseDone:
|
||||
// count tools token
|
||||
if !info.IsFirstRequest {
|
||||
if info.RealtimeTools != nil && len(info.RealtimeTools) > 0 {
|
||||
for _, tool := range info.RealtimeTools {
|
||||
toolTokens := CountTokenInput(tool, model)
|
||||
textToken += 8
|
||||
textToken += toolTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return textToken, audioToken, nil
|
||||
}
|
||||
|
||||
func CountTokenInput(input any, model string) int {
|
||||
switch v := input.(type) {
|
||||
case string:
|
||||
return CountTextToken(v, model)
|
||||
case []string:
|
||||
text := ""
|
||||
for _, s := range v {
|
||||
text += s
|
||||
}
|
||||
return CountTextToken(text, model)
|
||||
case []interface{}:
|
||||
text := ""
|
||||
for _, item := range v {
|
||||
text += fmt.Sprintf("%v", item)
|
||||
}
|
||||
return CountTextToken(text, model)
|
||||
}
|
||||
return CountTokenInput(fmt.Sprintf("%v", input), model)
|
||||
}
|
||||
|
||||
func CountAudioTokenInput(audioBase64 string, audioFormat string) (int, error) {
|
||||
if audioBase64 == "" {
|
||||
return 0, nil
|
||||
}
|
||||
duration, err := parseAudio(audioBase64, audioFormat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(duration / 60 * 100 / 0.06), nil
|
||||
}
|
||||
|
||||
func CountAudioTokenOutput(audioBase64 string, audioFormat string) (int, error) {
|
||||
if audioBase64 == "" {
|
||||
return 0, nil
|
||||
}
|
||||
duration, err := parseAudio(audioBase64, audioFormat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(duration / 60 * 200 / 0.24), nil
|
||||
}
|
||||
|
||||
// CountTextToken 统计文本的token数量,仅OpenAI模型使用tokenizer,其余模型使用估算
|
||||
func CountTextToken(text string, model string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
if common.IsOpenAITextModel(model) {
|
||||
tokenEncoder := getTokenEncoder(model)
|
||||
return getTokenNum(tokenEncoder, text)
|
||||
} else {
|
||||
// 非openai模型,使用tiktoken-go计算没有意义,使用估算节省资源
|
||||
return EstimateTokenByModel(model, text)
|
||||
}
|
||||
}
|
||||
230
service/token_estimator.go
Normal file
230
service/token_estimator.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Provider 定义模型厂商大类
|
||||
type Provider string
|
||||
|
||||
const (
|
||||
OpenAI Provider = "openai" // 代表 GPT-3.5, GPT-4, GPT-4o
|
||||
Gemini Provider = "gemini" // 代表 Gemini 1.0, 1.5 Pro/Flash
|
||||
Claude Provider = "claude" // 代表 Claude 3, 3.5 Sonnet
|
||||
Unknown Provider = "unknown" // 兜底默认
|
||||
)
|
||||
|
||||
// multipliers 定义不同厂商的计费权重
|
||||
type multipliers struct {
|
||||
Word float64 // 英文单词 (每词)
|
||||
Number float64 // 数字 (每连续数字串)
|
||||
CJK float64 // 中日韩字符 (每字)
|
||||
Symbol float64 // 普通标点符号 (每个)
|
||||
MathSymbol float64 // 数学符号 (∑,∫,∂,√等,每个)
|
||||
URLDelim float64 // URL分隔符 (/,:,?,&,=,#,%) - tokenizer优化好
|
||||
AtSign float64 // @符号 - 导致单词切分,消耗较高
|
||||
Emoji float64 // Emoji表情 (每个)
|
||||
Newline float64 // 换行符/制表符 (每个)
|
||||
Space float64 // 空格 (每个)
|
||||
BasePad int // 基础起步消耗 (Start/End tokens)
|
||||
}
|
||||
|
||||
var (
|
||||
multipliersMap = map[Provider]multipliers{
|
||||
Gemini: {
|
||||
Word: 1.15, Number: 2.8, CJK: 0.68, Symbol: 0.38, MathSymbol: 1.05, URLDelim: 1.2, AtSign: 2.5, Emoji: 1.08, Newline: 1.15, Space: 0.2, BasePad: 0,
|
||||
},
|
||||
Claude: {
|
||||
Word: 1.13, Number: 1.63, CJK: 1.21, Symbol: 0.4, MathSymbol: 4.52, URLDelim: 1.26, AtSign: 2.82, Emoji: 2.6, Newline: 0.89, Space: 0.39, BasePad: 0,
|
||||
},
|
||||
OpenAI: {
|
||||
Word: 1.02, Number: 1.55, CJK: 0.85, Symbol: 0.4, MathSymbol: 2.68, URLDelim: 1.0, AtSign: 2.0, Emoji: 2.12, Newline: 0.5, Space: 0.42, BasePad: 0,
|
||||
},
|
||||
}
|
||||
multipliersLock sync.RWMutex
|
||||
)
|
||||
|
||||
// getMultipliers 根据厂商获取权重配置
|
||||
func getMultipliers(p Provider) multipliers {
|
||||
multipliersLock.RLock()
|
||||
defer multipliersLock.RUnlock()
|
||||
|
||||
switch p {
|
||||
case Gemini:
|
||||
return multipliersMap[Gemini]
|
||||
case Claude:
|
||||
return multipliersMap[Claude]
|
||||
case OpenAI:
|
||||
return multipliersMap[OpenAI]
|
||||
default:
|
||||
// 默认兜底 (按 OpenAI 的算)
|
||||
return multipliersMap[OpenAI]
|
||||
}
|
||||
}
|
||||
|
||||
// EstimateToken 计算 Token 数量
|
||||
func EstimateToken(provider Provider, text string) int {
|
||||
m := getMultipliers(provider)
|
||||
var count float64
|
||||
|
||||
// 状态机变量
|
||||
type WordType int
|
||||
const (
|
||||
None WordType = iota
|
||||
Latin
|
||||
Number
|
||||
)
|
||||
currentWordType := None
|
||||
|
||||
for _, r := range text {
|
||||
// 1. 处理空格和换行符
|
||||
if unicode.IsSpace(r) {
|
||||
currentWordType = None
|
||||
// 换行符和制表符使用Newline权重
|
||||
if r == '\n' || r == '\t' {
|
||||
count += m.Newline
|
||||
} else {
|
||||
// 普通空格使用Space权重
|
||||
count += m.Space
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. 处理 CJK (中日韩) - 按字符计费
|
||||
if isCJK(r) {
|
||||
currentWordType = None
|
||||
count += m.CJK
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. 处理Emoji - 使用专门的Emoji权重
|
||||
if isEmoji(r) {
|
||||
currentWordType = None
|
||||
count += m.Emoji
|
||||
continue
|
||||
}
|
||||
|
||||
// 4. 处理拉丁字母/数字 (英文单词)
|
||||
if isLatinOrNumber(r) {
|
||||
isNum := unicode.IsNumber(r)
|
||||
newType := Latin
|
||||
if isNum {
|
||||
newType = Number
|
||||
}
|
||||
|
||||
// 如果之前不在单词中,或者类型发生变化(字母<->数字),则视为新token
|
||||
// 注意:对于OpenAI,通常"version 3.5"会切分,"abc123xyz"有时也会切分
|
||||
// 这里简单起见,字母和数字切换时增加权重
|
||||
if currentWordType == None || currentWordType != newType {
|
||||
if newType == Number {
|
||||
count += m.Number
|
||||
} else {
|
||||
count += m.Word
|
||||
}
|
||||
currentWordType = newType
|
||||
}
|
||||
// 单词中间的字符不额外计费
|
||||
continue
|
||||
}
|
||||
|
||||
// 5. 处理标点符号/特殊字符 - 按类型使用不同权重
|
||||
currentWordType = None
|
||||
if isMathSymbol(r) {
|
||||
count += m.MathSymbol
|
||||
} else if r == '@' {
|
||||
count += m.AtSign
|
||||
} else if isURLDelim(r) {
|
||||
count += m.URLDelim
|
||||
} else {
|
||||
count += m.Symbol
|
||||
}
|
||||
}
|
||||
|
||||
// 向上取整并加上基础 padding
|
||||
return int(math.Ceil(count)) + m.BasePad
|
||||
}
|
||||
|
||||
// 辅助:判断是否为 CJK 字符
|
||||
func isCJK(r rune) bool {
|
||||
return unicode.Is(unicode.Han, r) ||
|
||||
(r >= 0x3040 && r <= 0x30FF) || // 日文
|
||||
(r >= 0xAC00 && r <= 0xD7A3) // 韩文
|
||||
}
|
||||
|
||||
// 辅助:判断是否为单词主体 (字母或数字)
|
||||
func isLatinOrNumber(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsNumber(r)
|
||||
}
|
||||
|
||||
// 辅助:判断是否为Emoji字符
|
||||
func isEmoji(r rune) bool {
|
||||
// Emoji的Unicode范围
|
||||
// 基本范围:0x1F300-0x1F9FF (Emoticons, Symbols, Pictographs)
|
||||
// 补充范围:0x2600-0x26FF (Misc Symbols), 0x2700-0x27BF (Dingbats)
|
||||
// 表情符号:0x1F600-0x1F64F (Emoticons)
|
||||
// 其他:0x1F900-0x1F9FF (Supplemental Symbols and Pictographs)
|
||||
return (r >= 0x1F300 && r <= 0x1F9FF) ||
|
||||
(r >= 0x2600 && r <= 0x26FF) ||
|
||||
(r >= 0x2700 && r <= 0x27BF) ||
|
||||
(r >= 0x1F600 && r <= 0x1F64F) ||
|
||||
(r >= 0x1F900 && r <= 0x1F9FF) ||
|
||||
(r >= 0x1FA00 && r <= 0x1FAFF) // Symbols and Pictographs Extended-A
|
||||
}
|
||||
|
||||
// 辅助:判断是否为数学符号
|
||||
func isMathSymbol(r rune) bool {
|
||||
// 数学运算符和符号
|
||||
// 基本数学符号:∑ ∫ ∂ √ ∞ ≤ ≥ ≠ ≈ ± × ÷
|
||||
// 上下标数字:² ³ ¹ ⁴ ⁵ ⁶ ⁷ ⁸ ⁹ ⁰
|
||||
// 希腊字母等也常用于数学
|
||||
mathSymbols := "∑∫∂√∞≤≥≠≈±×÷∈∉∋∌⊂⊃⊆⊇∪∩∧∨¬∀∃∄∅∆∇∝∟∠∡∢°′″‴⁺⁻⁼⁽⁾ⁿ₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎²³¹⁴⁵⁶⁷⁸⁹⁰"
|
||||
for _, m := range mathSymbols {
|
||||
if r == m {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Mathematical Operators (U+2200–U+22FF)
|
||||
if r >= 0x2200 && r <= 0x22FF {
|
||||
return true
|
||||
}
|
||||
// Supplemental Mathematical Operators (U+2A00–U+2AFF)
|
||||
if r >= 0x2A00 && r <= 0x2AFF {
|
||||
return true
|
||||
}
|
||||
// Mathematical Alphanumeric Symbols (U+1D400–U+1D7FF)
|
||||
if r >= 0x1D400 && r <= 0x1D7FF {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 辅助:判断是否为URL分隔符(tokenizer对这些优化较好)
|
||||
func isURLDelim(r rune) bool {
|
||||
// URL中常见的分隔符,tokenizer通常优化处理
|
||||
urlDelims := "/:?&=;#%"
|
||||
for _, d := range urlDelims {
|
||||
if r == d {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func EstimateTokenByModel(model, text string) int {
|
||||
// strings.Contains(model, "gpt-4o")
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
model = strings.ToLower(model)
|
||||
if strings.Contains(model, "gemini") {
|
||||
return EstimateToken(Gemini, text)
|
||||
} else if strings.Contains(model, "claude") {
|
||||
return EstimateToken(Claude, text)
|
||||
} else {
|
||||
return EstimateToken(OpenAI, text)
|
||||
}
|
||||
}
|
||||
63
service/tokenizer.go
Normal file
63
service/tokenizer.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/tiktoken-go/tokenizer"
|
||||
"github.com/tiktoken-go/tokenizer/codec"
|
||||
)
|
||||
|
||||
// tokenEncoderMap won't grow after initialization
|
||||
var defaultTokenEncoder tokenizer.Codec
|
||||
|
||||
// tokenEncoderMap is used to store token encoders for different models
|
||||
var tokenEncoderMap = make(map[string]tokenizer.Codec)
|
||||
|
||||
// tokenEncoderMutex protects tokenEncoderMap for concurrent access
|
||||
var tokenEncoderMutex sync.RWMutex
|
||||
|
||||
func InitTokenEncoders() {
|
||||
common.SysLog("initializing token encoders")
|
||||
defaultTokenEncoder = codec.NewCl100kBase()
|
||||
common.SysLog("token encoders initialized")
|
||||
}
|
||||
|
||||
func getTokenEncoder(model string) tokenizer.Codec {
|
||||
// First, try to get the encoder from cache with read lock
|
||||
tokenEncoderMutex.RLock()
|
||||
if encoder, exists := tokenEncoderMap[model]; exists {
|
||||
tokenEncoderMutex.RUnlock()
|
||||
return encoder
|
||||
}
|
||||
tokenEncoderMutex.RUnlock()
|
||||
|
||||
// If not in cache, create new encoder with write lock
|
||||
tokenEncoderMutex.Lock()
|
||||
defer tokenEncoderMutex.Unlock()
|
||||
|
||||
// Double-check if another goroutine already created the encoder
|
||||
if encoder, exists := tokenEncoderMap[model]; exists {
|
||||
return encoder
|
||||
}
|
||||
|
||||
// Create new encoder
|
||||
modelCodec, err := tokenizer.ForModel(tokenizer.Model(model))
|
||||
if err != nil {
|
||||
// Cache the default encoder for this model to avoid repeated failures
|
||||
tokenEncoderMap[model] = defaultTokenEncoder
|
||||
return defaultTokenEncoder
|
||||
}
|
||||
|
||||
// Cache the new encoder
|
||||
tokenEncoderMap[model] = modelCodec
|
||||
return modelCodec
|
||||
}
|
||||
|
||||
func getTokenNum(tokenEncoder tokenizer.Codec, text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
tkm, _ := tokenEncoder.Count(text)
|
||||
return tkm
|
||||
}
|
||||
33
service/usage_helpr.go
Normal file
33
service/usage_helpr.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//func GetPromptTokens(textRequest dto.GeneralOpenAIRequest, relayMode int) (int, error) {
|
||||
// switch relayMode {
|
||||
// case constant.RelayModeChatCompletions:
|
||||
// return CountTokenMessages(textRequest.Messages, textRequest.Model)
|
||||
// case constant.RelayModeCompletions:
|
||||
// return CountTokenInput(textRequest.Prompt, textRequest.Model), nil
|
||||
// case constant.RelayModeModerations:
|
||||
// return CountTokenInput(textRequest.Input, textRequest.Model), nil
|
||||
// }
|
||||
// return 0, errors.New("unknown relay mode")
|
||||
//}
|
||||
|
||||
func ResponseText2Usage(c *gin.Context, responseText string, modeName string, promptTokens int) *dto.Usage {
|
||||
common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true)
|
||||
usage := &dto.Usage{}
|
||||
usage.PromptTokens = promptTokens
|
||||
usage.CompletionTokens = EstimateTokenByModel(modeName, responseText)
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
return usage
|
||||
}
|
||||
|
||||
func ValidUsage(usage *dto.Usage) bool {
|
||||
return usage != nil && (usage.PromptTokens != 0 || usage.CompletionTokens != 0)
|
||||
}
|
||||
281
service/user_notify.go
Normal file
281
service/user_notify.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
)
|
||||
|
||||
func NotifyRootUser(t string, subject string, content string) {
|
||||
user := model.GetRootUser().ToBaseUser()
|
||||
err := NotifyUser(user.Id, user.Email, user.GetSetting(), dto.NewNotify(t, subject, content, nil))
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to notify root user: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func NotifyUpstreamModelUpdateWatchers(subject string, content string) {
|
||||
var users []model.User
|
||||
if err := model.DB.
|
||||
Select("id", "email", "role", "status", "setting").
|
||||
Where("status = ? AND role >= ?", common.UserStatusEnabled, common.RoleAdminUser).
|
||||
Find(&users).Error; err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to query upstream update notification users: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
notification := dto.NewNotify(dto.NotifyTypeChannelUpdate, subject, content, nil)
|
||||
sentCount := 0
|
||||
for _, user := range users {
|
||||
userSetting := user.GetSetting()
|
||||
if !userSetting.UpstreamModelUpdateNotifyEnabled {
|
||||
continue
|
||||
}
|
||||
if err := NotifyUser(user.Id, user.Email, userSetting, notification); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to notify user %d for upstream model update: %s", user.Id, err.Error()))
|
||||
continue
|
||||
}
|
||||
sentCount++
|
||||
}
|
||||
common.SysLog(fmt.Sprintf("upstream model update notifications sent: %d", sentCount))
|
||||
}
|
||||
|
||||
func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data dto.Notify) error {
|
||||
notifyType := userSetting.NotifyType
|
||||
if notifyType == "" {
|
||||
notifyType = dto.NotifyTypeEmail
|
||||
}
|
||||
|
||||
// Check notification limit
|
||||
canSend, err := CheckNotificationLimit(userId, data.Type)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to check notification limit: %s", err.Error()))
|
||||
return err
|
||||
}
|
||||
if !canSend {
|
||||
return fmt.Errorf("notification limit exceeded for user %d with type %s", userId, notifyType)
|
||||
}
|
||||
|
||||
switch notifyType {
|
||||
case dto.NotifyTypeEmail:
|
||||
// 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱
|
||||
emailToUse := userSetting.NotificationEmail
|
||||
if emailToUse == "" {
|
||||
emailToUse = userEmail
|
||||
}
|
||||
if emailToUse == "" {
|
||||
common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId))
|
||||
return nil
|
||||
}
|
||||
return sendEmailNotify(emailToUse, data)
|
||||
case dto.NotifyTypeWebhook:
|
||||
webhookURLStr := userSetting.WebhookUrl
|
||||
if webhookURLStr == "" {
|
||||
common.SysLog(fmt.Sprintf("user %d has no webhook url, skip sending webhook", userId))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取 webhook secret
|
||||
webhookSecret := userSetting.WebhookSecret
|
||||
return SendWebhookNotify(webhookURLStr, webhookSecret, data)
|
||||
case dto.NotifyTypeBark:
|
||||
barkURL := userSetting.BarkUrl
|
||||
if barkURL == "" {
|
||||
common.SysLog(fmt.Sprintf("user %d has no bark url, skip sending bark", userId))
|
||||
return nil
|
||||
}
|
||||
return sendBarkNotify(barkURL, data)
|
||||
case dto.NotifyTypeGotify:
|
||||
gotifyUrl := userSetting.GotifyUrl
|
||||
gotifyToken := userSetting.GotifyToken
|
||||
if gotifyUrl == "" || gotifyToken == "" {
|
||||
common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId))
|
||||
return nil
|
||||
}
|
||||
return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendEmailNotify(userEmail string, data dto.Notify) error {
|
||||
// make email content
|
||||
content := data.Content
|
||||
// 处理占位符
|
||||
for _, value := range data.Values {
|
||||
content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
|
||||
}
|
||||
return common.SendEmail(data.Title, userEmail, content)
|
||||
}
|
||||
|
||||
func sendBarkNotify(barkURL string, data dto.Notify) error {
|
||||
// 处理占位符
|
||||
content := data.Content
|
||||
for _, value := range data.Values {
|
||||
content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
|
||||
}
|
||||
|
||||
// 替换模板变量
|
||||
finalURL := strings.ReplaceAll(barkURL, "{{title}}", url.QueryEscape(data.Title))
|
||||
finalURL = strings.ReplaceAll(finalURL, "{{content}}", url.QueryEscape(content))
|
||||
|
||||
// 发送GET请求到Bark
|
||||
var req *http.Request
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
if system_setting.EnableWorker() {
|
||||
// 使用worker发送请求
|
||||
workerReq := &WorkerRequest{
|
||||
URL: finalURL,
|
||||
Key: system_setting.WorkerValidKey,
|
||||
Method: http.MethodGet,
|
||||
Headers: map[string]string{
|
||||
"User-Agent": "OneAPI-Bark-Notify/1.0",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err = DoWorkerRequest(workerReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send bark request through worker: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
} else {
|
||||
// SSRF防护:验证Bark URL(非Worker模式)
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||
return fmt.Errorf("request reject: %v", err)
|
||||
}
|
||||
|
||||
// 直接发送请求
|
||||
req, err = http.NewRequest(http.MethodGet, finalURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bark request: %v", err)
|
||||
}
|
||||
|
||||
// 设置User-Agent
|
||||
req.Header.Set("User-Agent", "OneAPI-Bark-Notify/1.0")
|
||||
|
||||
// 发送请求
|
||||
client := GetHttpClient()
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send bark request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error {
|
||||
// 处理占位符
|
||||
content := data.Content
|
||||
for _, value := range data.Values {
|
||||
content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
|
||||
}
|
||||
|
||||
// 构建完整的 Gotify API URL
|
||||
// 确保 URL 以 /message 结尾
|
||||
finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken)
|
||||
|
||||
// Gotify优先级范围0-10,如果超出范围则使用默认值5
|
||||
if priority < 0 || priority > 10 {
|
||||
priority = 5
|
||||
}
|
||||
|
||||
// 构建 JSON payload
|
||||
type GotifyMessage struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
payload := GotifyMessage{
|
||||
Title: data.Title,
|
||||
Message: content,
|
||||
Priority: priority,
|
||||
}
|
||||
|
||||
// 序列化为 JSON
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal gotify payload: %v", err)
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
var resp *http.Response
|
||||
|
||||
if system_setting.EnableWorker() {
|
||||
// 使用worker发送请求
|
||||
workerReq := &WorkerRequest{
|
||||
URL: finalURL,
|
||||
Key: system_setting.WorkerValidKey,
|
||||
Method: http.MethodPost,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"User-Agent": "OneAPI-Gotify-Notify/1.0",
|
||||
},
|
||||
Body: payloadBytes,
|
||||
}
|
||||
|
||||
resp, err = DoWorkerRequest(workerReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send gotify request through worker: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
} else {
|
||||
// SSRF防护:验证Gotify URL(非Worker模式)
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||
return fmt.Errorf("request reject: %v", err)
|
||||
}
|
||||
|
||||
// 直接发送请求
|
||||
req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create gotify request: %v", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0")
|
||||
|
||||
// 发送请求
|
||||
client := GetHttpClient()
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send gotify request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
164
service/violation_fee.go
Normal file
164
service/violation_fee.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
ViolationFeeCodePrefix = "violation_fee."
|
||||
CSAMViolationMarker = "Failed check: SAFETY_CHECK_TYPE"
|
||||
ContentViolatesUsageMarker = "Content violates usage guidelines"
|
||||
)
|
||||
|
||||
func IsViolationFeeCode(code types.ErrorCode) bool {
|
||||
return strings.HasPrefix(string(code), ViolationFeeCodePrefix)
|
||||
}
|
||||
|
||||
func HasCSAMViolationMarker(err *types.NewAPIError) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(err.Error(), CSAMViolationMarker) || strings.Contains(err.Error(), ContentViolatesUsageMarker) {
|
||||
return true
|
||||
}
|
||||
msg := err.ToOpenAIError().Message
|
||||
return strings.Contains(msg, CSAMViolationMarker) || strings.Contains(err.Error(), ContentViolatesUsageMarker)
|
||||
}
|
||||
|
||||
func WrapAsViolationFeeGrokCSAM(err *types.NewAPIError) *types.NewAPIError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
oai := err.ToOpenAIError()
|
||||
oai.Type = string(types.ErrorCodeViolationFeeGrokCSAM)
|
||||
oai.Code = string(types.ErrorCodeViolationFeeGrokCSAM)
|
||||
return types.WithOpenAIError(oai, err.StatusCode, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
// NormalizeViolationFeeError ensures:
|
||||
// - if the CSAM marker is present, error.code is set to a stable violation-fee code and skip-retry is enabled.
|
||||
// - if error.code already has the violation-fee prefix, skip-retry is enabled.
|
||||
//
|
||||
// It must be called before retry decision logic.
|
||||
func NormalizeViolationFeeError(err *types.NewAPIError) *types.NewAPIError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if HasCSAMViolationMarker(err) {
|
||||
return WrapAsViolationFeeGrokCSAM(err)
|
||||
}
|
||||
|
||||
if IsViolationFeeCode(err.GetErrorCode()) {
|
||||
oai := err.ToOpenAIError()
|
||||
return types.WithOpenAIError(oai, err.StatusCode, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func shouldChargeViolationFee(err *types.NewAPIError) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if err.GetErrorCode() == types.ErrorCodeViolationFeeGrokCSAM {
|
||||
return true
|
||||
}
|
||||
// In case some callers didn't normalize, keep a safety net.
|
||||
return HasCSAMViolationMarker(err)
|
||||
}
|
||||
|
||||
func calcViolationFeeQuota(amount, groupRatio float64) int {
|
||||
if amount <= 0 {
|
||||
return 0
|
||||
}
|
||||
if groupRatio <= 0 {
|
||||
return 0
|
||||
}
|
||||
quota := decimal.NewFromFloat(amount).
|
||||
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Mul(decimal.NewFromFloat(groupRatio)).
|
||||
Round(0).
|
||||
IntPart()
|
||||
if quota <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(quota)
|
||||
}
|
||||
|
||||
// ChargeViolationFeeIfNeeded charges an additional fee after the normal flow finishes (including refund).
|
||||
// It uses Grok fee settings as the fee policy.
|
||||
func ChargeViolationFeeIfNeeded(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, apiErr *types.NewAPIError) bool {
|
||||
if ctx == nil || relayInfo == nil || apiErr == nil {
|
||||
return false
|
||||
}
|
||||
//if relayInfo.IsPlayground {
|
||||
// return false
|
||||
//}
|
||||
if !shouldChargeViolationFee(apiErr) {
|
||||
return false
|
||||
}
|
||||
|
||||
settings := model_setting.GetGrokSettings()
|
||||
if settings == nil || !settings.ViolationDeductionEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
|
||||
feeQuota := calcViolationFeeQuota(settings.ViolationDeductionAmount, groupRatio)
|
||||
if feeQuota <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := PostConsumeQuota(relayInfo, feeQuota, 0, true); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("failed to charge violation fee: %s", err.Error()))
|
||||
return false
|
||||
}
|
||||
|
||||
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, feeQuota)
|
||||
model.UpdateChannelUsedQuota(relayInfo.ChannelId, feeQuota)
|
||||
|
||||
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
||||
tokenName := ctx.GetString("token_name")
|
||||
oai := apiErr.ToOpenAIError()
|
||||
|
||||
other := map[string]any{
|
||||
"violation_fee": true,
|
||||
"violation_fee_code": string(types.ErrorCodeViolationFeeGrokCSAM),
|
||||
"fee_quota": feeQuota,
|
||||
"base_amount": settings.ViolationDeductionAmount,
|
||||
"group_ratio": groupRatio,
|
||||
"status_code": apiErr.StatusCode,
|
||||
"upstream_error_type": oai.Type,
|
||||
"upstream_error_code": fmt.Sprintf("%v", oai.Code),
|
||||
"violation_fee_marker": CSAMViolationMarker,
|
||||
}
|
||||
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||
ChannelId: relayInfo.ChannelId,
|
||||
ModelName: relayInfo.OriginModelName,
|
||||
TokenName: tokenName,
|
||||
Quota: feeQuota,
|
||||
Content: "Violation fee charged",
|
||||
TokenId: relayInfo.TokenId,
|
||||
UseTimeSeconds: int(useTimeSeconds),
|
||||
IsStream: relayInfo.IsStream,
|
||||
Group: relayInfo.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
126
service/webhook.go
Normal file
126
service/webhook.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
)
|
||||
|
||||
// WebhookPayload webhook 通知的负载数据
|
||||
type WebhookPayload struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Values []interface{} `json:"values,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// generateSignature 生成 webhook 签名
|
||||
func generateSignature(secret string, payload []byte) string {
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write(payload)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// SendWebhookNotify 发送 webhook 通知
|
||||
func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error {
|
||||
// 处理占位符
|
||||
content := data.Content
|
||||
for _, value := range data.Values {
|
||||
content = fmt.Sprintf(content, value)
|
||||
}
|
||||
|
||||
// 构建 webhook 负载
|
||||
payload := WebhookPayload{
|
||||
Type: data.Type,
|
||||
Title: data.Title,
|
||||
Content: content,
|
||||
Values: data.Values,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// 序列化负载
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal webhook payload: %v", err)
|
||||
}
|
||||
|
||||
// 创建 HTTP 请求
|
||||
var req *http.Request
|
||||
var resp *http.Response
|
||||
|
||||
if system_setting.EnableWorker() {
|
||||
// 构建worker请求数据
|
||||
workerReq := &WorkerRequest{
|
||||
URL: webhookURL,
|
||||
Key: system_setting.WorkerValidKey,
|
||||
Method: http.MethodPost,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
Body: payloadBytes,
|
||||
}
|
||||
|
||||
// 如果有secret,添加签名到headers
|
||||
if secret != "" {
|
||||
signature := generateSignature(secret, payloadBytes)
|
||||
workerReq.Headers["X-Webhook-Signature"] = signature
|
||||
workerReq.Headers["Authorization"] = "Bearer " + secret
|
||||
}
|
||||
|
||||
resp, err = DoWorkerRequest(workerReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send webhook request through worker: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
} else {
|
||||
// SSRF防护:验证Webhook URL(非Worker模式)
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||
return fmt.Errorf("request reject: %v", err)
|
||||
}
|
||||
|
||||
req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create webhook request: %v", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 如果有 secret,生成签名
|
||||
if secret != "" {
|
||||
signature := generateSignature(secret, payloadBytes)
|
||||
req.Header.Set("X-Webhook-Signature", signature)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
client := GetHttpClient()
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send webhook request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user