Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions common/endpoint_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
constant.EndpointTypeOpenAIResponseCompact: {Path: "/v1/responses/compact", Method: "POST"},
constant.EndpointTypeOpenAIAlphaSearch: {Path: "/v1/alpha/search", Method: "POST"},
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"},
Expand All @@ -32,3 +33,13 @@ func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
info, ok := defaultEndpointInfoMap[et]
return info, ok
}

// GetDefaultEndpointTypeByPath returns the built-in endpoint type for an exact path.
func GetDefaultEndpointTypeByPath(path string) (constant.EndpointType, bool) {
for endpointType, info := range defaultEndpointInfoMap {
if info.Path == path {
return endpointType, true
}
}
return "", false
}
6 changes: 6 additions & 0 deletions common/endpoint_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
case constant.ChannelTypeSora:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
case constant.ChannelTypeCodex:
endpointTypes = []constant.EndpointType{
constant.EndpointTypeOpenAIResponse,
constant.EndpointTypeOpenAIResponseCompact,
constant.EndpointTypeOpenAIAlphaSearch,
}
default:
if IsOpenAIResponseOnlyModel(modelName) {
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}
Expand Down
16 changes: 16 additions & 0 deletions common/endpoint_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package common

import (
"testing"

"github.com/MAX-API-Next/MAX-API/constant"
"github.com/stretchr/testify/require"
)

func TestCodexEndpointTypesIncludeAlphaSearch(t *testing.T) {
endpointTypes := GetEndpointTypesByChannelType(constant.ChannelTypeCodex, "gpt-5.1")

require.Contains(t, endpointTypes, constant.EndpointTypeOpenAIResponse)
require.Contains(t, endpointTypes, constant.EndpointTypeOpenAIResponseCompact)
require.Contains(t, endpointTypes, constant.EndpointTypeOpenAIAlphaSearch)
}
1 change: 1 addition & 0 deletions constant/endpoint_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (
EndpointTypeOpenAI EndpointType = "openai"
EndpointTypeOpenAIResponse EndpointType = "openai-response"
EndpointTypeOpenAIResponseCompact EndpointType = "openai-response-compact"
EndpointTypeOpenAIAlphaSearch EndpointType = "openai-alpha-search"
EndpointTypeAnthropic EndpointType = "anthropic"
EndpointTypeGemini EndpointType = "gemini"
EndpointTypeJinaRerank EndpointType = "jina-rerank"
Expand Down
116 changes: 73 additions & 43 deletions controller/billing.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,52 @@
package controller

import (
"fmt"
"math"
"net/http"

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/model"
"github.com/MAX-API-Next/MAX-API/setting/operation_setting"
"github.com/MAX-API-Next/MAX-API/types"
"github.com/gin-gonic/gin"
)

const billingLookupErrorMessage = "billing information unavailable"

func billingQuotaDisplayAmount(quota int64) (float64, error) {
amount := float64(quota)
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
return amount, nil
}
if common.QuotaPerUnit <= 0 || math.IsNaN(common.QuotaPerUnit) || math.IsInf(common.QuotaPerUnit, 0) {
return 0, fmt.Errorf("invalid QuotaPerUnit: %g", common.QuotaPerUnit)
}
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeCNY {
if operation_setting.USDExchangeRate <= 0 || math.IsNaN(operation_setting.USDExchangeRate) || math.IsInf(operation_setting.USDExchangeRate, 0) {
return 0, fmt.Errorf("invalid USDExchangeRate: %g", operation_setting.USDExchangeRate)
}
return amount / common.QuotaPerUnit * operation_setting.USDExchangeRate, nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return amount / common.QuotaPerUnit, nil
}

func billingSubscriptionDisplayAmount(quota int64, unlimitedToken bool) (float64, error) {
if unlimitedToken {
return 100000000, nil
}
return billingQuotaDisplayAmount(quota)
}

func writeBillingOpenAIError(c *gin.Context, status int, message string, errorType string) {
c.JSON(status, gin.H{
"error": types.OpenAIError{
Message: message,
Type: errorType,
},
})
}

func GetSubscription(c *gin.Context) {
var remainQuota int64
var usedQuota int64
Expand All @@ -17,45 +56,39 @@ func GetSubscription(c *gin.Context) {
if common.DisplayTokenStatEnabled {
tokenId := c.GetInt("token_id")
token, err = model.GetTokenById(tokenId)
if err != nil {
common.SysError(fmt.Sprintf("billing subscription token lookup failed: token_id=%d: %v", tokenId, err))
writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "upstream_error")
return
}
expiredTime = token.ExpiredTime
remainQuota = int64(token.RemainQuota)
usedQuota = int64(token.UsedQuota)
} else {
userId := c.GetInt("id")
remainQuota, err = model.GetUserQuota(userId, false)
if err != nil {
common.SysError(fmt.Sprintf("billing subscription quota lookup failed: user_id=%d: %v", userId, err))
writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "upstream_error")
return
}
usedQuota, err = model.GetUserUsedQuota(userId)
if err != nil {
common.SysError(fmt.Sprintf("billing subscription used-quota lookup failed: user_id=%d: %v", userId, err))
writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "upstream_error")
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if expiredTime <= 0 {
expiredTime = 0
}
quota := remainQuota + usedQuota
amount, err := billingSubscriptionDisplayAmount(quota, token != nil && token.UnlimitedQuota)
if err != nil {
openAIError := types.OpenAIError{
Message: err.Error(),
Type: "upstream_error",
}
c.JSON(200, gin.H{
"error": openAIError,
})
common.SysError("billing subscription quota display failed: " + err.Error())
writeBillingOpenAIError(c, http.StatusInternalServerError, err.Error(), "max_api_error")
return
}
quota := remainQuota + usedQuota
amount := float64(quota)
// OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
// 我们将其解释为以“站点展示类型”为准:
// - USD: 直接除以 QuotaPerUnit
// - CNY: 先转 USD 再乘汇率
// - TOKENS: 直接使用 tokens 数量
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// amount 保持 tokens 数值
default:
amount = amount / common.QuotaPerUnit
}
if token != nil && token.UnlimitedQuota {
amount = 100000000
}
subscription := OpenAISubscriptionResponse{
Object: "billing_subscription",
HasPaymentMethod: true,
Expand All @@ -64,7 +97,7 @@ func GetSubscription(c *gin.Context) {
SystemHardLimitUSD: amount,
AccessUntil: expiredTime,
}
c.JSON(200, subscription)
c.JSON(http.StatusOK, subscription)
return
}

Expand All @@ -75,34 +108,31 @@ func GetUsage(c *gin.Context) {
if common.DisplayTokenStatEnabled {
tokenId := c.GetInt("token_id")
token, err = model.GetTokenById(tokenId)
if err != nil {
common.SysError(fmt.Sprintf("billing usage token lookup failed: token_id=%d: %v", tokenId, err))
writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "max_api_error")
return
}
quota = int64(token.UsedQuota)
} else {
userId := c.GetInt("id")
quota, err = model.GetUserUsedQuota(userId)
if err != nil {
common.SysError(fmt.Sprintf("billing usage quota lookup failed: user_id=%d: %v", userId, err))
writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "max_api_error")
return
}
}
amount, err := billingQuotaDisplayAmount(quota)
if err != nil {
openAIError := types.OpenAIError{
Message: err.Error(),
Type: "max_api_error",
}
c.JSON(200, gin.H{
"error": openAIError,
})
common.SysError("billing usage quota display failed: " + err.Error())
writeBillingOpenAIError(c, http.StatusInternalServerError, err.Error(), "max_api_error")
return
}
amount := float64(quota)
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// tokens 保持原值
default:
amount = amount / common.QuotaPerUnit
}
usage := OpenAIUsageResponse{
Object: "list",
TotalUsage: amount * 100,
}
c.JSON(200, usage)
c.JSON(http.StatusOK, usage)
return
}
118 changes: 118 additions & 0 deletions controller/billing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package controller

import (
"math"
"testing"

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/setting/operation_setting"
"github.com/stretchr/testify/require"
)

func TestBillingQuotaDisplayAmountRejectsInvalidQuotaPerUnit(t *testing.T) {
oldQuotaPerUnit := common.QuotaPerUnit
oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
t.Cleanup(func() {
common.QuotaPerUnit = oldQuotaPerUnit
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
})

for _, quotaPerUnit := range []float64{0, -1, math.NaN(), math.Inf(1)} {
t.Run("invalid_quota_per_unit", func(t *testing.T) {
common.QuotaPerUnit = quotaPerUnit
operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD

_, err := billingQuotaDisplayAmount(1000)

require.Error(t, err)
})
}
}

func TestBillingQuotaDisplayAmountAllowsTokenDisplayWithInvalidQuotaPerUnit(t *testing.T) {
oldQuotaPerUnit := common.QuotaPerUnit
oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
t.Cleanup(func() {
common.QuotaPerUnit = oldQuotaPerUnit
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
})

common.QuotaPerUnit = 0
operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeTokens

amount, err := billingQuotaDisplayAmount(1000)

require.NoError(t, err)
require.Equal(t, 1000.0, amount)
}

func TestBillingQuotaDisplayAmountRejectsInvalidUSDExchangeRate(t *testing.T) {
oldQuotaPerUnit := common.QuotaPerUnit
oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
oldUSDExchangeRate := operation_setting.USDExchangeRate
t.Cleanup(func() {
common.QuotaPerUnit = oldQuotaPerUnit
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
operation_setting.USDExchangeRate = oldUSDExchangeRate
})

common.QuotaPerUnit = 500
operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeCNY

tests := []struct {
name string
usdExchangeRate float64
}{
{name: "zero", usdExchangeRate: 0},
{name: "negative", usdExchangeRate: -1},
{name: "nan", usdExchangeRate: math.NaN()},
{name: "infinite", usdExchangeRate: math.Inf(1)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
operation_setting.USDExchangeRate = tt.usdExchangeRate

_, err := billingQuotaDisplayAmount(1000)

require.Error(t, err)
})
}
}

func TestBillingQuotaDisplayAmountAllowsValidUSDExchangeRate(t *testing.T) {
oldQuotaPerUnit := common.QuotaPerUnit
oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
oldUSDExchangeRate := operation_setting.USDExchangeRate
t.Cleanup(func() {
common.QuotaPerUnit = oldQuotaPerUnit
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
operation_setting.USDExchangeRate = oldUSDExchangeRate
})

common.QuotaPerUnit = 500
operation_setting.USDExchangeRate = 7.2
operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeCNY

amount, err := billingQuotaDisplayAmount(1000)

require.NoError(t, err)
require.Equal(t, 14.4, amount)
}

func TestBillingSubscriptionDisplayAmountAllowsUnlimitedTokenWithInvalidQuotaPerUnit(t *testing.T) {
oldQuotaPerUnit := common.QuotaPerUnit
oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
t.Cleanup(func() {
common.QuotaPerUnit = oldQuotaPerUnit
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
})

common.QuotaPerUnit = 0
operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD

amount, err := billingSubscriptionDisplayAmount(0, true)

require.NoError(t, err)
require.Equal(t, 100000000.0, amount)
}
Loading
Loading