Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ data/
token_estimator_test.go
skills-lock.json
.playwright-mcp
.ace-tool/

# Local-only live probes and scratch test workspaces.
.local-tests/
Expand Down
8 changes: 7 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ ENV GO111MODULE=on CGO_ENABLED=0

ARG TARGETOS
ARG TARGETARCH
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
ENV GOEXPERIMENT=greenteagc
ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}

WORKDIR /build

ADD go.mod go.sum ./
RUN go mod download
RUN sh -c 'go mod download || go mod download || go mod download'

COPY . .
COPY --from=builder /build/web/default/dist ./web/default/dist
Expand All @@ -40,6 +43,9 @@ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$

FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a

RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
&& rm -rf /var/lib/apt/lists/* \
Expand Down
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20260719
33 changes: 17 additions & 16 deletions constant/context_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,23 @@ const (
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"

/* channel related keys */
ContextKeyChannelId ContextKey = "channel_id"
ContextKeyChannelName ContextKey = "channel_name"
ContextKeyChannelCreateTime ContextKey = "channel_create_time"
ContextKeyChannelBaseUrl ContextKey = "base_url"
ContextKeyChannelType ContextKey = "channel_type"
ContextKeyChannelSetting ContextKey = "channel_setting"
ContextKeyChannelOtherSetting ContextKey = "channel_other_setting"
ContextKeyChannelParamOverride ContextKey = "param_override"
ContextKeyChannelHeaderOverride ContextKey = "header_override"
ContextKeyChannelOrganization ContextKey = "channel_organization"
ContextKeyChannelAutoBan ContextKey = "auto_ban"
ContextKeyChannelModelMapping ContextKey = "model_mapping"
ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping"
ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key"
ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index"
ContextKeyChannelKey ContextKey = "channel_key"
ContextKeyChannelId ContextKey = "channel_id"
ContextKeyChannelName ContextKey = "channel_name"
ContextKeyChannelCreateTime ContextKey = "channel_create_time"
ContextKeyChannelBaseUrl ContextKey = "base_url"
ContextKeyChannelType ContextKey = "channel_type"
ContextKeyChannelSetting ContextKey = "channel_setting"
ContextKeyChannelOtherSetting ContextKey = "channel_other_setting"
ContextKeyChannelParamOverride ContextKey = "param_override"
ContextKeyChannelHeaderOverride ContextKey = "header_override"
ContextKeyChannelOrganization ContextKey = "channel_organization"
ContextKeyChannelAutoBan ContextKey = "auto_ban"
ContextKeyChannelModelMapping ContextKey = "model_mapping"
ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping"
ContextKeyChannelStatusCodeResponseMapping ContextKey = "status_code_response_mapping"
ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key"
ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index"
ContextKeyChannelKey ContextKey = "channel_key"

ContextKeyAutoGroup ContextKey = "auto_group"
ContextKeyAutoGroupIndex ContextKey = "auto_group_index"
Expand Down
18 changes: 11 additions & 7 deletions controller/channel-test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
newAPIError := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
common.SysError(fmt.Sprintf(
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
channel.Id,
Expand All @@ -449,12 +449,12 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
testModel,
endpointType,
httpResp.StatusCode,
err,
newAPIError,
))
return testResult{
context: c,
localErr: err,
newAPIError: types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError),
localErr: newAPIError,
newAPIError: newAPIError,
}
}
}
Expand Down Expand Up @@ -859,13 +859,16 @@ func TestChannel(c *gin.Context) {
}
result := testChannel(requestCtx, channel, testUserID, testModel, endpointType, isStream)
if result.localErr != nil {
message := result.localErr.Error()
resp := gin.H{
"success": false,
"message": result.localErr.Error(),
"message": message,
"time": 0.0,
}
if result.newAPIError != nil {
resp["error_code"] = result.newAPIError.GetErrorCode()
service.ApplyStatusCodeResponseMapping(result.newAPIError, channel.GetStatusCodeResponseMapping())
resp["message"] = result.newAPIError.Error()
resp["error_code"] = result.newAPIError.ToOpenAIError().Code
}
c.JSON(http.StatusOK, resp)
return
Expand All @@ -875,11 +878,12 @@ func TestChannel(c *gin.Context) {
go channel.UpdateResponseTime(milliseconds)
consumedTime := float64(milliseconds) / 1000.0
if result.newAPIError != nil {
service.ApplyStatusCodeResponseMapping(result.newAPIError, channel.GetStatusCodeResponseMapping())
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": result.newAPIError.Error(),
"time": consumedTime,
"error_code": result.newAPIError.GetErrorCode(),
"error_code": result.newAPIError.ToOpenAIError().Code,
})
return
}
Expand Down
9 changes: 8 additions & 1 deletion controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,14 +465,21 @@ func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {

// validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error {
if channel == nil {
return fmt.Errorf("channel cannot be empty")
}

// 校验 channel settings
if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
}
if err := service.ValidateStatusCodeResponseMapping(channel.GetStatusCodeResponseMapping()); err != nil {
return fmt.Errorf("渠道状态码响应映射[status_code_response_mapping] 格式错误:%s", err.Error())
}

// 如果是添加操作,检查 channel 和 key 是否为空
if isAdd {
if channel == nil || channel.Key == "" {
if channel.Key == "" {
return fmt.Errorf("channel cannot be empty")
}

Expand Down
31 changes: 16 additions & 15 deletions controller/channel_authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,20 @@ func clearChannelReadOnlyFields(channel *PatchChannel, requestData map[string]an
// to the fail-closed branch and is treated as sensitive. The
// TestChannelFieldsAreClassified guard test enforces this.
var channelNonSensitiveFields = map[string]struct{}{
"id": {},
"test_model": {},
"name": {},
"weight": {},
"models": {},
"group": {},
"model_mapping": {},
"status_code_mapping": {},
"priority": {},
"auto_ban": {},
"other_info": {},
"tag": {},
"remark": {},
"channel_info": {},
"multi_key_mode": {},
"id": {},
"test_model": {},
"name": {},
"weight": {},
"models": {},
"group": {},
"model_mapping": {},
"status_code_mapping": {},
"status_code_response_mapping": {},
"priority": {},
"auto_ban": {},
"other_info": {},
"tag": {},
"remark": {},
"channel_info": {},
"multi_key_mode": {},
}
16 changes: 10 additions & 6 deletions controller/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,11 @@ func GetLogsStat(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"quota": stat.Quota,
"rpm": stat.Rpm,
"tpm": stat.Tpm,
"quota": stat.Quota,
"rpm": stat.Rpm,
"tpm": stat.Tpm,
"cache_tokens": stat.CacheTokens,
"cache_hit_rate": stat.CacheHitRate,
},
})
return
Expand All @@ -141,9 +143,11 @@ func GetLogsSelfStat(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"quota": quotaNum.Quota,
"rpm": quotaNum.Rpm,
"tpm": quotaNum.Tpm,
"quota": quotaNum.Quota,
"rpm": quotaNum.Rpm,
"tpm": quotaNum.Tpm,
"cache_tokens": quotaNum.CacheTokens,
"cache_hit_rate": quotaNum.CacheHitRate,
//"token": tokenNum,
},
})
Expand Down
19 changes: 15 additions & 4 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
defer func() {
if newAPIError != nil {
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
service.ApplyStatusCodeResponseMapping(newAPIError, c.GetString(string(constant.ContextKeyChannelStatusCodeResponseMapping)))
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
switch relayFormat {
case types.RelayFormatOpenAIRealtime:
Expand Down Expand Up @@ -355,7 +356,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
}

func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
statusCodeResponseMapping := c.GetString(string(constant.ContextKeyChannelStatusCodeResponseMapping))
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d, original status code: %d): %s",
channelError.ChannelId, err.StatusCode, err.GetOriginalStatusCode(), common.LocalLogPreview(err.Error())))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(err) && channelError.AutoBan {
Expand All @@ -378,7 +381,10 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
}
other["error_type"] = err.GetErrorType()
other["error_code"] = err.GetErrorCode()
other["status_code"] = err.StatusCode
other["original_status_code"] = err.GetOriginalStatusCode()
// Prefer the user-facing status after response mapping so log metadata
// matches the status clients actually receive from Relay's final error response.
other["status_code"] = service.ResolveStatusCodeWithResponseMapping(err, statusCodeResponseMapping)
other["channel_id"] = channelId
other["channel_name"] = c.GetString("channel_name")
other["channel_type"] = c.GetInt("channel_type")
Expand All @@ -396,7 +402,11 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
// Error logs previously recorded the raw upstream body before response mapping
// was applied in Relay's defer. Use the mapped user-facing content so admin log
// details match what clients receive.
logContent := service.FormatErrorLogWithStatusCodeResponseMapping(err, statusCodeResponseMapping)
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, logContent, tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
}

}
Expand Down Expand Up @@ -607,7 +617,8 @@ func RelayTask(c *gin.Context) {

// respondTaskError 统一输出 Task 错误响应(含 429 限流提示改写)
func respondTaskError(c *gin.Context, taskErr *dto.TaskError) {
if taskErr.StatusCode == http.StatusTooManyRequests {
messageOverridden := service.ApplyStatusCodeResponseMappingToTaskError(taskErr, c.GetString(string(constant.ContextKeyChannelStatusCodeResponseMapping)))
if taskErr.StatusCode == http.StatusTooManyRequests && !messageOverridden {
taskErr.Message = "当前分组上游负载已饱和,请稍后再试"
}
c.JSON(taskErr.StatusCode, taskErr)
Expand Down
15 changes: 15 additions & 0 deletions controller/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
return
}
}
req.Plan.AllowedGroups = model.NormalizeSubscriptionAllowedGroups(req.Plan.AllowedGroups)
for _, groupName := range model.ParseSubscriptionAllowedGroups(req.Plan.AllowedGroups) {
if _, ok := ratio_setting.GetGroupRatioCopy()[groupName]; !ok {
common.ApiErrorMsg(c, fmt.Sprintf("抵扣分组不存在: %s", groupName))
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
Expand Down Expand Up @@ -273,6 +280,13 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
return
}
}
req.Plan.AllowedGroups = model.NormalizeSubscriptionAllowedGroups(req.Plan.AllowedGroups)
for _, groupName := range model.ParseSubscriptionAllowedGroups(req.Plan.AllowedGroups) {
if _, ok := ratio_setting.GetGroupRatioCopy()[groupName]; !ok {
common.ApiErrorMsg(c, fmt.Sprintf("抵扣分组不存在: %s", groupName))
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
Expand All @@ -298,6 +312,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
"total_amount": req.Plan.TotalAmount,
"upgrade_group": req.Plan.UpgradeGroup,
"downgrade_group": req.Plan.DowngradeGroup,
"allowed_groups": req.Plan.AllowedGroups,
"quota_reset_period": req.Plan.QuotaResetPeriod,
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
"updated_at": common.GetTimestamp(),
Expand Down
32 changes: 32 additions & 0 deletions controller/usedata.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,35 @@ func GetUserFlowQuotaDates(c *gin.Context) {
})
return
}

func GetModelCacheStats(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
username := c.Query("username")
stats, err := model.SumCacheTokensByModel(startTimestamp, endTimestamp, username)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": stats,
})
}

func GetUserModelCacheStats(c *gin.Context) {
username := c.GetString("username")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
stats, err := model.SumCacheTokensByModel(startTimestamp, endTimestamp, username)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": stats,
})
}
13 changes: 7 additions & 6 deletions dto/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import (
)

type TaskError struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
StatusCode int `json:"-"`
LocalError bool `json:"-"`
Error error `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
StatusCode int `json:"-"`
OriginalStatusCode int `json:"-"`
LocalError bool `json:"-"`
Error error `json:"-"`
}

type TaskData interface {
Expand Down
1 change: 1 addition & 0 deletions middleware/distributor.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
common.SetContextKey(c, constant.ContextKeyChannelAutoBan, channel.GetAutoBan())
common.SetContextKey(c, constant.ContextKeyChannelModelMapping, channel.GetModelMapping())
common.SetContextKey(c, constant.ContextKeyChannelStatusCodeMapping, channel.GetStatusCodeMapping())
common.SetContextKey(c, constant.ContextKeyChannelStatusCodeResponseMapping, channel.GetStatusCodeResponseMapping())

key, index, newAPIError := channel.GetNextEnabledKey()
if newAPIError != nil {
Expand Down
Loading