diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml
index 116dd1452152..567ca6235994 100644
--- a/.github/workflows/docker-image-alpha.yml
+++ b/.github/workflows/docker-image-alpha.yml
@@ -4,6 +4,7 @@ on:
push:
branches:
- alpha
+ - cooper
workflow_dispatch:
inputs:
name:
@@ -34,10 +35,16 @@ jobs:
with:
fetch-depth: 1
- - name: Determine alpha version
+ - name: Determine image channel
+ run: |
+ CHANNEL="${GITHUB_REF_NAME:-alpha}"
+ echo "CHANNEL=$CHANNEL" >> $GITHUB_ENV
+ echo "Publishing channel: $CHANNEL"
+
+ - name: Determine channel version
id: version
run: |
- VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
+ VERSION="${CHANNEL}-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
echo "$VERSION" > VERSION
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -49,12 +56,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- - name: Log in to Docker Hub
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
@@ -67,10 +68,9 @@ jobs:
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: |
- calciumion/new-api
ghcr.io/${{ env.GHCR_REPOSITORY }}
- - name: Build & push single-arch (to both registries)
+ - name: Build & push single-arch (to GHCR)
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
@@ -78,9 +78,7 @@ jobs:
platforms: ${{ matrix.platform }}
push: true
tags: |
- calciumion/new-api:alpha-${{ matrix.arch }}
- calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }}
- ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}
+ ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.CHANNEL }}-${{ matrix.arch }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
@@ -92,21 +90,18 @@ jobs:
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
- name: Sign image with cosign
- run: |
- cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
- cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }}
+ run: cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }}
- name: Output digest
run: |
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- echo "calciumion/new-api:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
- echo "ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
+ echo "ghcr.io/${GHCR_REPOSITORY}:${CHANNEL}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
create_manifests:
- name: Create multi-arch manifests (Docker Hub + GHCR)
+ name: Create multi-arch manifests (GHCR)
needs: [build_single_arch]
runs-on: ubuntu-latest
permissions:
@@ -121,33 +116,19 @@ jobs:
- name: Normalize GHCR repository
run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- - name: Determine alpha version
+ - name: Determine image channel
+ run: |
+ CHANNEL="${GITHUB_REF_NAME:-alpha}"
+ echo "CHANNEL=$CHANNEL" >> $GITHUB_ENV
+ echo "Publishing channel: $CHANNEL"
+
+ - name: Determine channel version
id: version
run: |
- VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
+ VERSION="${CHANNEL}-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
- - name: Log in to Docker Hub
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Create & push manifest (Docker Hub - alpha)
- run: |
- docker buildx imagetools create \
- -t calciumion/new-api:alpha \
- calciumion/new-api:alpha-amd64 \
- calciumion/new-api:alpha-arm64
-
- - name: Create & push manifest (Docker Hub - versioned alpha)
- run: |
- docker buildx imagetools create \
- -t calciumion/new-api:${VERSION} \
- calciumion/new-api:${VERSION}-amd64 \
- calciumion/new-api:${VERSION}-arm64
-
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
@@ -155,14 +136,14 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Create & push manifest (GHCR - alpha)
+ - name: Create & push manifest (GHCR - channel)
run: |
docker buildx imagetools create \
- -t ghcr.io/${GHCR_REPOSITORY}:alpha \
- ghcr.io/${GHCR_REPOSITORY}:alpha-amd64 \
- ghcr.io/${GHCR_REPOSITORY}:alpha-arm64
+ -t ghcr.io/${GHCR_REPOSITORY}:${CHANNEL} \
+ ghcr.io/${GHCR_REPOSITORY}:${CHANNEL}-amd64 \
+ ghcr.io/${GHCR_REPOSITORY}:${CHANNEL}-arm64
- - name: Create & push manifest (GHCR - versioned alpha)
+ - name: Create & push manifest (GHCR - versioned channel)
run: |
docker buildx imagetools create \
-t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \
@@ -173,7 +154,5 @@ jobs:
run: |
echo "### Multi-arch Manifest Digests" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- docker buildx imagetools inspect calciumion/new-api:alpha >> $GITHUB_STEP_SUMMARY
- echo "---" >> $GITHUB_STEP_SUMMARY
- docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:alpha >> $GITHUB_STEP_SUMMARY
+ docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:${CHANNEL} >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
diff --git a/README.md b/README.md
index ab6f1499e967..d53988fe7f54 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
+测试下这是我自己的修改

diff --git a/common/endpoint_type.go b/common/endpoint_type.go
index a5e2ff8412e8..bd423592b7b6 100644
--- a/common/endpoint_type.go
+++ b/common/endpoint_type.go
@@ -28,7 +28,7 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
case constant.ChannelTypeXai:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
- case constant.ChannelTypeSora:
+ case constant.ChannelTypeSora, constant.ChannelTypeDoubaoVideo:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
default:
if IsOpenAIResponseOnlyModel(modelName) {
@@ -41,5 +41,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
// add to first
endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...)
}
+ if IsVideoGenerationModel(modelName) {
+ endpointTypes = append([]constant.EndpointType{constant.EndpointTypeOpenAIVideo}, endpointTypes...)
+ }
return endpointTypes
}
diff --git a/common/model.go b/common/model.go
index 4ebc7b532d74..240d8af84c5b 100644
--- a/common/model.go
+++ b/common/model.go
@@ -17,6 +17,18 @@ var (
"flux-",
"flux.1-",
}
+ VideoGenerationModels = []string{
+ "doubao-seedance-",
+ "seedance-",
+ "sora-",
+ "veo-",
+ "kling",
+ "vidu",
+ "hailuo",
+ "jimeng",
+ "cogvideo",
+ "video",
+ }
OpenAITextModels = []string{
"gpt-",
"o1",
@@ -48,6 +60,19 @@ func IsImageGenerationModel(modelName string) bool {
return false
}
+func IsVideoGenerationModel(modelName string) bool {
+ modelName = strings.ToLower(modelName)
+ for _, m := range VideoGenerationModels {
+ if strings.Contains(modelName, m) {
+ return true
+ }
+ if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) {
+ return true
+ }
+ }
+ return false
+}
+
func IsOpenAITextModel(modelName string) bool {
modelName = strings.ToLower(modelName)
for _, m := range OpenAITextModels {
diff --git a/controller/ai_translation.go b/controller/ai_translation.go
new file mode 100644
index 000000000000..413b2473a4fe
--- /dev/null
+++ b/controller/ai_translation.go
@@ -0,0 +1,106 @@
+package controller
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+)
+
+type AITranslationSettingsRequest struct {
+ Enabled any `json:"enabled"`
+ BaseURL string `json:"base_url"`
+ APIKey string `json:"api_key"`
+ Model string `json:"model"`
+ TimeoutSeconds any `json:"timeout_seconds"`
+}
+
+func UpdateAITranslationSettings(c *gin.Context) {
+ var req AITranslationSettingsRequest
+ if err := common.DecodeJson(c.Request.Body, &req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "message": "invalid request",
+ })
+ return
+ }
+
+ updates := map[string]string{
+ "AITranslationEnabled": common.Interface2String(req.Enabled),
+ "AITranslationBaseURL": strings.TrimSpace(req.BaseURL),
+ "AITranslationModel": strings.TrimSpace(req.Model),
+ "AITranslationTimeoutSeconds": common.Interface2String(req.TimeoutSeconds),
+ }
+ if strings.TrimSpace(req.APIKey) != "" {
+ updates["AITranslationAPIKey"] = strings.TrimSpace(req.APIKey)
+ }
+
+ for key, value := range updates {
+ if err := model.UpdateOption(key, value); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ })
+}
+
+func GenerateAITranslations(c *gin.Context) {
+ sources := make([]service.AITranslationSource, 0, 8)
+
+ collectSource := func(scope string, build func() any, paths []string) {
+ start := time.Now()
+ payload := build()
+ sources = append(sources, service.AITranslationSource{Scope: scope, Payload: payload, Paths: paths})
+ common.SysLog("AI translation source collected: scope=" + scope + ", elapsed=" + time.Since(start).String())
+ }
+
+ collectSource("status", func() any { return buildStatusResponse() }, statusTranslationPaths)
+ collectSource("notice", func() any { return buildNoticeResponse() }, noticeTranslationPaths)
+ collectSource("user_groups", func() any { return buildUserGroupsResponse("default") }, userGroupsTranslationPaths)
+ collectSource("pricing", func() any { return buildPricingResponse("default") }, pricingTranslationPaths)
+
+ start := time.Now()
+ if plansResp, err := buildSubscriptionPlansResponse(); err == nil {
+ sources = append(sources, service.AITranslationSource{Scope: "subscription_plans", Payload: plansResp, Paths: subscriptionPlansTranslationPaths})
+ common.SysLog("AI translation source collected: scope=subscription_plans, elapsed=" + time.Since(start).String())
+ } else {
+ common.SysLog("AI translation source skipped: scope=subscription_plans, error=" + err.Error() + ", elapsed=" + time.Since(start).String())
+ }
+
+ start = time.Now()
+ if rankingsResp, err := buildRankingsResponse("week"); err == nil {
+ sources = append(sources, service.AITranslationSource{Scope: "rankings", Payload: rankingsResp, Paths: rankingsTranslationPaths})
+ common.SysLog("AI translation source collected: scope=rankings, elapsed=" + time.Since(start).String())
+ } else {
+ common.SysLog("AI translation source skipped: scope=rankings, error=" + err.Error() + ", elapsed=" + time.Since(start).String())
+ }
+
+ start = time.Now()
+ snapshot, err := service.GenerateAITranslationSnapshot(c.Request.Context(), sources)
+ if err != nil {
+ common.SysLog("AI translation snapshot failed: elapsed=" + time.Since(start).String() + ", error=" + err.Error())
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+ common.SysLog("AI translation snapshot generated: elapsed=" + time.Since(start).String())
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": gin.H{
+ "updated_at": snapshot.UpdatedAt,
+ "stats": snapshot.Stats,
+ },
+ })
+}
diff --git a/controller/ai_translation_paths.go b/controller/ai_translation_paths.go
new file mode 100644
index 000000000000..ccf5414ede2d
--- /dev/null
+++ b/controller/ai_translation_paths.go
@@ -0,0 +1,53 @@
+package controller
+
+var statusTranslationPaths = []string{
+ "data.announcements.*.content",
+ "data.announcements.*.extra",
+ "data.api_info.*.description",
+ "data.api_info.*.route",
+ "data.chats.*.@key",
+ "data.faq.*.answer",
+ "data.faq.*.question",
+}
+
+var noticeTranslationPaths = []string{
+ "data",
+}
+
+var uptimeTranslationPaths = []string{
+ "data.*.categoryName",
+ "data.*.monitors.*.group",
+ "data.*.monitors.*.name",
+}
+
+var userGroupsTranslationPaths = []string{
+ "data.@key",
+ "data.*.desc",
+}
+
+var subscriptionPlansTranslationPaths = []string{
+ "data.*.plan.subtitle",
+ "data.*.plan.title",
+}
+
+var pricingTranslationPaths = []string{
+ "auto_groups.*",
+ "data.*.enable_groups.*",
+ "data.*.description",
+ "data.*.tags",
+ "group_ratio.@key",
+ "usable_group.@key",
+ "usable_group.@value",
+ "vendors.*.name",
+}
+
+var rankingsTranslationPaths = []string{
+ "data.models.*.vendor",
+ "data.models_history.models.*.vendor",
+ "data.models_history.points.*.vendor",
+ "data.top_droppers.*.vendor",
+ "data.top_movers.*.vendor",
+ "data.vendor_share_history.points.*.vendor",
+ "data.vendor_share_history.vendors.*.name",
+ "data.vendors.*.vendor",
+}
diff --git a/controller/billing_statistics.go b/controller/billing_statistics.go
new file mode 100644
index 000000000000..ac48da4e929f
--- /dev/null
+++ b/controller/billing_statistics.go
@@ -0,0 +1,29 @@
+package controller
+
+import (
+ "strconv"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/gin-gonic/gin"
+)
+
+func GetBillingStatistics(c *gin.Context) {
+ startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+ endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
+ pageInfo := common.GetPageQuery(c)
+
+ result, err := model.GetBillingStatistics(model.BillingStatisticsQuery{
+ StartTimestamp: startTimestamp,
+ EndTimestamp: endTimestamp,
+ Granularity: c.Query("granularity"),
+ Username: c.Query("username"),
+ Page: pageInfo.GetPage(),
+ PageSize: pageInfo.GetPageSize(),
+ })
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, result)
+}
diff --git a/controller/error_request_snapshot.go b/controller/error_request_snapshot.go
new file mode 100644
index 000000000000..cd68bff0fd9e
--- /dev/null
+++ b/controller/error_request_snapshot.go
@@ -0,0 +1,201 @@
+package controller
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/gin-gonic/gin"
+)
+
+const errorRequestSnapshotBodyLimit = 16 * 1024
+
+type errorRequestSnapshot struct {
+ Method string `json:"method,omitempty"`
+ Path string `json:"path,omitempty"`
+ Query string `json:"query,omitempty"`
+ ContentType string `json:"content_type,omitempty"`
+ ContentLength int64 `json:"content_length,omitempty"`
+ Headers map[string][]string `json:"headers,omitempty"`
+ Body any `json:"body,omitempty"`
+ BodyPreview string `json:"body_preview,omitempty"`
+ BodyTruncated bool `json:"body_truncated,omitempty"`
+ BodyError string `json:"body_error,omitempty"`
+}
+
+func buildErrorRequestSnapshot(c *gin.Context) map[string]any {
+ if c == nil || c.Request == nil {
+ return nil
+ }
+ req := c.Request
+ snapshot := errorRequestSnapshot{
+ Method: req.Method,
+ ContentType: req.Header.Get("Content-Type"),
+ ContentLength: req.ContentLength,
+ Headers: sanitizeRequestHeaders(req.Header),
+ }
+ if req.URL != nil {
+ snapshot.Path = req.URL.Path
+ snapshot.Query = req.URL.RawQuery
+ }
+
+ bodyBytes, truncated, err := readRequestSnapshotBody(c)
+ if err != nil {
+ snapshot.BodyError = err.Error()
+ } else if len(bodyBytes) > 0 {
+ snapshot.BodyTruncated = truncated
+ if isJSONSnapshotContentType(snapshot.ContentType) {
+ var parsed any
+ if err := common.Unmarshal(bodyBytes, &parsed); err == nil {
+ snapshot.Body = sanitizeJSONValue(parsed)
+ } else {
+ snapshot.BodyPreview = string(bodyBytes)
+ snapshot.BodyError = fmt.Sprintf("parse json failed: %s", err.Error())
+ }
+ } else if isTextSnapshotContentType(snapshot.ContentType) {
+ snapshot.BodyPreview = string(bodyBytes)
+ } else {
+ snapshot.BodyPreview = fmt.Sprintf("<%d bytes omitted: %s>", len(bodyBytes), snapshot.ContentType)
+ }
+ }
+
+ out := map[string]any{
+ "method": snapshot.Method,
+ "path": snapshot.Path,
+ "query": snapshot.Query,
+ "content_type": snapshot.ContentType,
+ "content_length": snapshot.ContentLength,
+ "headers": snapshot.Headers,
+ "body": snapshot.Body,
+ "body_preview": snapshot.BodyPreview,
+ "body_truncated": snapshot.BodyTruncated,
+ "body_error": snapshot.BodyError,
+ }
+ for key, value := range out {
+ if isEmptySnapshotValue(value) {
+ delete(out, key)
+ }
+ }
+ return out
+}
+
+func readRequestSnapshotBody(c *gin.Context) ([]byte, bool, error) {
+ storage, err := common.GetBodyStorage(c)
+ if err != nil {
+ return nil, false, err
+ }
+ bodyBytes, err := storage.Bytes()
+ if err != nil {
+ return nil, false, err
+ }
+ if len(bodyBytes) <= errorRequestSnapshotBodyLimit {
+ return bodyBytes, false, nil
+ }
+ return append([]byte(nil), bodyBytes[:errorRequestSnapshotBodyLimit]...), true, nil
+}
+
+func sanitizeRequestHeaders(headers http.Header) map[string][]string {
+ if len(headers) == 0 {
+ return nil
+ }
+ sanitized := make(map[string][]string, len(headers))
+ for key, values := range headers {
+ if isSensitiveSnapshotKey(key) {
+ sanitized[key] = []string{"***"}
+ continue
+ }
+ copied := make([]string, 0, len(values))
+ for _, value := range values {
+ copied = append(copied, sanitizeSnapshotString(value))
+ }
+ sanitized[key] = copied
+ }
+ return sanitized
+}
+
+func sanitizeJSONValue(value any) any {
+ switch typed := value.(type) {
+ case map[string]any:
+ out := make(map[string]any, len(typed))
+ for key, item := range typed {
+ if isSensitiveSnapshotKey(key) {
+ out[key] = "***"
+ } else {
+ out[key] = sanitizeJSONValue(item)
+ }
+ }
+ return out
+ case []any:
+ out := make([]any, 0, len(typed))
+ for _, item := range typed {
+ out = append(out, sanitizeJSONValue(item))
+ }
+ return out
+ case string:
+ return sanitizeSnapshotString(typed)
+ default:
+ return typed
+ }
+}
+
+func sanitizeSnapshotString(value string) string {
+ if value == "" {
+ return value
+ }
+ if len(value) > errorRequestSnapshotBodyLimit {
+ value = value[:errorRequestSnapshotBodyLimit] + "...(truncated)"
+ }
+ return common.MaskSensitiveInfo(value)
+}
+
+func isSensitiveSnapshotKey(key string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(key))
+ normalized = strings.ReplaceAll(normalized, "-", "_")
+ sensitiveFragments := []string{
+ "authorization",
+ "api_key",
+ "apikey",
+ "access_token",
+ "refresh_token",
+ "token",
+ "password",
+ "secret",
+ "cookie",
+ "credential",
+ }
+ for _, fragment := range sensitiveFragments {
+ if strings.Contains(normalized, fragment) {
+ return true
+ }
+ }
+ return false
+}
+
+func isJSONSnapshotContentType(contentType string) bool {
+ contentType = strings.ToLower(strings.TrimSpace(contentType))
+ return strings.HasPrefix(contentType, "application/json") || strings.Contains(contentType, "+json")
+}
+
+func isTextSnapshotContentType(contentType string) bool {
+ contentType = strings.ToLower(strings.TrimSpace(contentType))
+ return strings.HasPrefix(contentType, "text/") ||
+ strings.HasPrefix(contentType, "application/x-www-form-urlencoded")
+}
+
+func isEmptySnapshotValue(value any) bool {
+ switch typed := value.(type) {
+ case nil:
+ return true
+ case string:
+ return typed == ""
+ case int64:
+ return typed == 0
+ case bool:
+ return !typed
+ case map[string][]string:
+ return len(typed) == 0
+ default:
+ return false
+ }
+}
diff --git a/controller/group.go b/controller/group.go
index 6ba339a3f9bd..b24900b21ee0 100644
--- a/controller/group.go
+++ b/controller/group.go
@@ -24,10 +24,15 @@ func GetGroups(c *gin.Context) {
}
func GetUserGroups(c *gin.Context) {
- usableGroups := make(map[string]map[string]interface{})
userGroup := ""
userId := c.GetInt("id")
userGroup, _ = model.GetUserGroup(userId, false)
+ resp := buildUserGroupsResponse(userGroup)
+ c.JSON(http.StatusOK, service.TranslateAPIResponse(c, "user_groups", resp, userGroupsTranslationPaths))
+}
+
+func buildUserGroupsResponse(userGroup string) gin.H {
+ usableGroups := make(map[string]map[string]interface{})
userUsableGroups := service.GetUserUsableGroups(userGroup)
for groupName, _ := range ratio_setting.GetGroupRatioCopy() {
// UserUsableGroups contains the groups that the user can use
@@ -44,9 +49,10 @@ func GetUserGroups(c *gin.Context) {
"desc": setting.GetUsableGroupDescription("auto"),
}
}
- c.JSON(http.StatusOK, gin.H{
+ resp := gin.H{
"success": true,
"message": "",
"data": usableGroups,
- })
+ }
+ return resp
}
diff --git a/controller/misc.go b/controller/misc.go
index 29b3a5c5e180..9c215746953c 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
+ "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -39,11 +40,12 @@ func TestStatus(c *gin.Context) {
return
}
-func GetStatus(c *gin.Context) {
-
+func buildStatusResponse() gin.H {
cs := console_setting.GetConsoleSetting()
common.OptionMapRWMutex.RLock()
- defer common.OptionMapRWMutex.RUnlock()
+ headerNavModules := common.OptionMap["HeaderNavModules"]
+ sidebarModulesAdmin := common.OptionMap["SidebarModulesAdmin"]
+ common.OptionMapRWMutex.RUnlock()
passkeySetting := system_setting.GetPasskeySettings()
legalSetting := system_setting.GetLegalSettings()
@@ -100,8 +102,8 @@ func GetStatus(c *gin.Context) {
"faq_enabled": cs.FAQEnabled,
// 模块管理配置
- "HeaderNavModules": common.OptionMap["HeaderNavModules"],
- "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
+ "HeaderNavModules": headerNavModules,
+ "SidebarModulesAdmin": sidebarModulesAdmin,
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
@@ -158,22 +160,34 @@ func GetStatus(c *gin.Context) {
data["custom_oauth_providers"] = providersInfo
}
- c.JSON(http.StatusOK, gin.H{
+ resp := gin.H{
"success": true,
"message": "",
"data": data,
- })
+ }
+ return resp
+}
+
+func GetStatus(c *gin.Context) {
+ resp := buildStatusResponse()
+ c.JSON(http.StatusOK, service.TranslateAPIResponse(c, "status", resp, statusTranslationPaths))
return
}
-func GetNotice(c *gin.Context) {
+func buildNoticeResponse() gin.H {
common.OptionMapRWMutex.RLock()
- defer common.OptionMapRWMutex.RUnlock()
- c.JSON(http.StatusOK, gin.H{
+ notice := common.OptionMap["Notice"]
+ common.OptionMapRWMutex.RUnlock()
+ return gin.H{
"success": true,
"message": "",
- "data": common.OptionMap["Notice"],
- })
+ "data": notice,
+ }
+}
+
+func GetNotice(c *gin.Context) {
+ resp := buildNoticeResponse()
+ c.JSON(http.StatusOK, service.TranslateAPIResponse(c, "notice", resp, noticeTranslationPaths))
return
}
diff --git a/controller/option.go b/controller/option.go
index 4849bcc675d0..da247fe3ed9d 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -46,11 +46,17 @@ func isVisiblePublicKeyOption(key string) bool {
switch key {
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
return true
+ case "AITranslationAPIKey":
+ return true
default:
return false
}
}
+func isHiddenOptionKey(key string) bool {
+ return key == "AITranslationSnapshot"
+}
+
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
if strings.TrimSpace(raw) == "" {
return
@@ -89,6 +95,9 @@ func GetOptions(c *gin.Context) {
optionValues := make(map[string]string)
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
+ if isHiddenOptionKey(k) {
+ continue
+ }
value := common.Interface2String(v)
isSensitiveKey := strings.HasSuffix(k, "Token") ||
strings.HasSuffix(k, "Secret") ||
@@ -286,6 +295,15 @@ func UpdateOption(c *gin.Context) {
})
return
}
+ case "ModelRequestConcurrencyLimitGroup":
+ err = setting.CheckModelRequestConcurrencyLimitGroup(option.Value.(string))
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
case "AutomaticDisableStatusCodes":
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
if err != nil {
diff --git a/controller/pricing.go b/controller/pricing.go
index 8252327244c4..f4b5fe66b9b3 100644
--- a/controller/pricing.go
+++ b/controller/pricing.go
@@ -34,27 +34,31 @@ func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string
}
func GetPricing(c *gin.Context) {
- pricing := model.GetPricing()
userId, exists := c.Get("id")
- usableGroup := map[string]string{}
- groupRatio := map[string]float64{}
- for s, f := range ratio_setting.GetGroupRatioCopy() {
- groupRatio[s] = f
- }
var group string
if exists {
user, err := model.GetUserCache(userId.(int))
if err == nil {
group = user.Group
- for g := range groupRatio {
- ratio, ok := ratio_setting.GetGroupGroupRatio(group, g)
- if ok {
- groupRatio[g] = ratio
- }
- }
}
}
+ resp := buildPricingResponse(group)
+ c.JSON(200, service.TranslateAPIResponse(c, "pricing", resp, pricingTranslationPaths))
+}
+func buildPricingResponse(group string) gin.H {
+ pricing := model.GetPricing()
+ usableGroup := map[string]string{}
+ groupRatio := map[string]float64{}
+ for s, f := range ratio_setting.GetGroupRatioCopy() {
+ groupRatio[s] = f
+ }
+ for g := range groupRatio {
+ ratio, ok := ratio_setting.GetGroupGroupRatio(group, g)
+ if ok {
+ groupRatio[g] = ratio
+ }
+ }
usableGroup = service.GetUserUsableGroups(group)
pricing = filterPricingByUsableGroups(pricing, usableGroup)
// check groupRatio contains usableGroup
@@ -64,7 +68,7 @@ func GetPricing(c *gin.Context) {
}
}
- c.JSON(200, gin.H{
+ return gin.H{
"success": true,
"data": pricing,
"vendors": model.GetVendors(),
@@ -73,7 +77,7 @@ func GetPricing(c *gin.Context) {
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": service.GetUserAutoGroup(group),
"pricing_version": "a42d372ccf0b5dd13ecf71203521f9d2",
- })
+ }
}
func ResetModelRatio(c *gin.Context) {
diff --git a/controller/rankings.go b/controller/rankings.go
index 5a7fdaae16b5..47a26b66f108 100644
--- a/controller/rankings.go
+++ b/controller/rankings.go
@@ -8,7 +8,7 @@ import (
)
func GetRankings(c *gin.Context) {
- result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week"))
+ resp, err := buildRankingsResponse(c.DefaultQuery("period", "week"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
@@ -16,9 +16,16 @@ func GetRankings(c *gin.Context) {
})
return
}
+ c.JSON(http.StatusOK, service.TranslateAPIResponse(c, "rankings", resp, rankingsTranslationPaths))
+}
- c.JSON(http.StatusOK, gin.H{
+func buildRankingsResponse(period string) (gin.H, error) {
+ result, err := service.GetRankingsSnapshot(period)
+ if err != nil {
+ return nil, err
+ }
+ return gin.H{
"success": true,
"data": result,
- })
+ }, nil
}
diff --git a/controller/relay.go b/controller/relay.go
index 5e2db44c25a4..b369fd118b05 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -124,10 +124,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
needSensitiveCheck := setting.ShouldCheckPromptSensitive()
+ needModerationCheck := setting.ModerationEnabled
needCountToken := constant.CountToken
- // Avoid building huge CombineText (strings.Join) when token counting and sensitive check are both disabled.
+ // Avoid building huge CombineText (strings.Join) when token counting, moderation, and sensitive checks are disabled.
var meta *types.TokenCountMeta
- if needSensitiveCheck || needCountToken {
+ if needSensitiveCheck || needModerationCheck || needCountToken {
meta = request.GetTokenCountMeta()
} else {
meta = fastTokenCountMetaForPricing(request)
@@ -137,11 +138,38 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
contains, words := service.CheckSensitiveText(meta.CombineText)
if contains {
logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", ")))
- newAPIError = types.NewError(err, types.ErrorCodeSensitiveWordsDetected)
+ newAPIError = types.NewErrorWithStatusCode(fmt.Errorf("sensitive words detected: %s", strings.Join(words, ", ")), types.ErrorCodeSensitiveWordsDetected, http.StatusForbidden, types.ErrOptionWithSkipRetry())
+ recordRelayPrecheckErrorLog(c, relayInfo, newAPIError, map[string]interface{}{
+ "reject_reason": "sensitive_words_detected",
+ "sensitive_words": words,
+ })
return
}
}
+ if needModerationCheck {
+ moderationResult, moderationErr := service.ModerateRelayRequest(c.Request.Context(), request, meta)
+ if moderationErr != nil {
+ logger.LogError(c, fmt.Sprintf("moderation check failed: %s", moderationErr.Error()))
+ if service.ModerationFailureModeClosed() {
+ newAPIError = types.NewErrorWithStatusCode(fmt.Errorf("moderation check failed"), types.ErrorCodeInvalidRequest, http.StatusForbidden, types.ErrOptionWithSkipRetry())
+ recordRelayModerationErrorLog(c, relayInfo, newAPIError, moderationResult, moderationErr)
+ return
+ }
+ c.Set("moderation_result", service.NewModerationErrorResult(moderationErr))
+ } else if moderationResult != nil && moderationResult.Action == "block" {
+ logger.LogWarn(c, fmt.Sprintf("moderation blocked request: %s", strings.Join(moderationResult.BlockedCategories, ", ")))
+ newAPIError = types.NewErrorWithStatusCode(fmt.Errorf("request content rejected by moderation"), types.ErrorCodeInvalidRequest, http.StatusForbidden, types.ErrOptionWithSkipRetry())
+ recordRelayModerationErrorLog(c, relayInfo, newAPIError, moderationResult, nil)
+ return
+ } else if moderationResult != nil && moderationResult.Action == "warn" {
+ c.Set("moderation_result", moderationResult)
+ logger.LogWarn(c, fmt.Sprintf("moderation flagged request: %s", strings.Join(moderationResult.FlaggedCategories, ", ")))
+ } else if moderationResult != nil {
+ c.Set("moderation_result", moderationResult)
+ }
+ }
+
tokens, err := service.EstimateRequestToken(c, meta, relayInfo)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeCountTokenFailed)
@@ -179,10 +207,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}()
retryParam := &service.RetryParam{
- Ctx: c,
- TokenGroup: relayInfo.TokenGroup,
- ModelName: relayInfo.OriginModelName,
- Retry: common.GetPointer(0),
+ Ctx: c,
+ TokenGroup: relayInfo.TokenGroup,
+ ModelName: relayInfo.OriginModelName,
+ Retry: common.GetPointer(0),
+ ExcludeChannelIds: map[int]bool{},
}
relayInfo.RetryIndex = 0
relayInfo.LastError = nil
@@ -197,6 +226,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
addUsedChannel(c, channel.Id)
+ retryParam.ExcludeChannelIds[channel.Id] = true
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
// Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path)
@@ -247,6 +277,84 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
}
+func recordRelayPrecheckErrorLog(c *gin.Context, relayInfo *relaycommon.RelayInfo, err *types.NewAPIError, extra map[string]interface{}) {
+ if !constant.ErrorLogEnabled || !types.IsRecordErrorLog(err) {
+ return
+ }
+ userId := c.GetInt("id")
+ tokenName := c.GetString("token_name")
+ modelName := c.GetString("original_model")
+ if modelName == "" && relayInfo != nil {
+ modelName = relayInfo.OriginModelName
+ }
+ tokenId := c.GetInt("token_id")
+ userGroup := c.GetString("group")
+ other := make(map[string]interface{})
+ if c.Request != nil && c.Request.URL != nil {
+ other["request_path"] = c.Request.URL.Path
+ }
+ other["error_type"] = err.GetErrorType()
+ other["error_code"] = err.GetErrorCode()
+ other["status_code"] = err.StatusCode
+ for key, value := range extra {
+ other[key] = value
+ }
+ adminInfo := make(map[string]interface{})
+ for key, value := range extra {
+ adminInfo[key] = value
+ }
+ if snapshot := buildErrorRequestSnapshot(c); len(snapshot) > 0 {
+ adminInfo["request_snapshot"] = snapshot
+ }
+ other["admin_info"] = adminInfo
+ startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
+ if startTime.IsZero() {
+ startTime = time.Now()
+ }
+ useTimeSeconds := int(time.Since(startTime).Seconds())
+ model.RecordErrorLog(c, userId, 0, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
+}
+
+func recordRelayModerationErrorLog(c *gin.Context, relayInfo *relaycommon.RelayInfo, err *types.NewAPIError, moderationResult *service.ModerationResult, moderationErr error) {
+ if !constant.ErrorLogEnabled || !types.IsRecordErrorLog(err) {
+ return
+ }
+ userId := c.GetInt("id")
+ tokenName := c.GetString("token_name")
+ modelName := c.GetString("original_model")
+ if modelName == "" && relayInfo != nil {
+ modelName = relayInfo.OriginModelName
+ }
+ tokenId := c.GetInt("token_id")
+ userGroup := c.GetString("group")
+ other := make(map[string]interface{})
+ if c.Request != nil && c.Request.URL != nil {
+ other["request_path"] = c.Request.URL.Path
+ }
+ other["error_type"] = err.GetErrorType()
+ other["error_code"] = err.GetErrorCode()
+ other["status_code"] = err.StatusCode
+ other["moderation"] = moderationResult
+ if moderationErr != nil {
+ other["moderation_error"] = moderationErr.Error()
+ }
+ adminInfo := make(map[string]interface{})
+ adminInfo["moderation"] = moderationResult
+ if moderationErr != nil {
+ adminInfo["moderation_error"] = moderationErr.Error()
+ }
+ if snapshot := buildErrorRequestSnapshot(c); len(snapshot) > 0 {
+ adminInfo["request_snapshot"] = snapshot
+ }
+ other["admin_info"] = adminInfo
+ startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
+ if startTime.IsZero() {
+ startTime = time.Now()
+ }
+ useTimeSeconds := int(time.Since(startTime).Seconds())
+ model.RecordErrorLog(c, userId, 0, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
+}
+
var upgrader = websocket.Upgrader{
Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol
CheckOrigin: func(r *http.Request) bool {
@@ -389,6 +497,9 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
}
service.AppendChannelAffinityAdminInfo(c, adminInfo)
+ if snapshot := buildErrorRequestSnapshot(c); len(snapshot) > 0 {
+ adminInfo["request_snapshot"] = snapshot
+ }
other["admin_info"] = adminInfo
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
@@ -507,10 +618,11 @@ func RelayTask(c *gin.Context) {
}()
retryParam := &service.RetryParam{
- Ctx: c,
- TokenGroup: relayInfo.TokenGroup,
- ModelName: relayInfo.OriginModelName,
- Retry: common.GetPointer(0),
+ Ctx: c,
+ TokenGroup: relayInfo.TokenGroup,
+ ModelName: relayInfo.OriginModelName,
+ Retry: common.GetPointer(0),
+ ExcludeChannelIds: map[int]bool{},
}
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
@@ -535,6 +647,7 @@ func RelayTask(c *gin.Context) {
}
addUsedChannel(c, channel.Id)
+ retryParam.ExcludeChannelIds[channel.Id] = true
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) {
@@ -589,6 +702,9 @@ func RelayTask(c *gin.Context) {
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
}
+ if taskReq, reqErr := relaycommon.GetTaskRequest(c); reqErr == nil {
+ task.Properties.Input = taskReq.Prompt
+ }
task.Quota = result.Quota
task.Data = result.TaskData
task.Action = relayInfo.Action
diff --git a/controller/subscription.go b/controller/subscription.go
index 4ce65249f09a..017160259633 100644
--- a/controller/subscription.go
+++ b/controller/subscription.go
@@ -6,6 +6,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
@@ -26,22 +27,31 @@ type BillingPreferenceRequest struct {
func GetSubscriptionPlans(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
- common.ApiSuccess(c, []SubscriptionPlanDTO{})
+ resp := gin.H{"success": true, "message": "", "data": []SubscriptionPlanDTO{}}
+ c.JSON(200, service.TranslateAPIResponse(c, "subscription_plans", resp, subscriptionPlansTranslationPaths))
return
}
- var plans []model.SubscriptionPlan
- if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
+ resp, err := buildSubscriptionPlansResponse()
+ if err != nil {
common.ApiError(c, err)
return
}
+ c.JSON(200, service.TranslateAPIResponse(c, "subscription_plans", resp, subscriptionPlansTranslationPaths))
+}
+
+func buildSubscriptionPlansResponse() (gin.H, error) {
+ var plans []model.SubscriptionPlan
+ if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
+ return nil, err
+ }
result := make([]SubscriptionPlanDTO, 0, len(plans))
for _, p := range plans {
result = append(result, SubscriptionPlanDTO{
Plan: p,
})
}
- common.ApiSuccess(c, result)
+ return gin.H{"success": true, "message": "", "data": result}, nil
}
func GetSubscriptionSelf(c *gin.Context) {
diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go
index 7dece6badce2..59f78837ade7 100644
--- a/controller/subscription_payment_epay.go
+++ b/controller/subscription_payment_epay.go
@@ -14,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
+ "github.com/shopspring/decimal"
)
type SubscriptionEpayPayRequest struct {
@@ -21,6 +22,10 @@ type SubscriptionEpayPayRequest struct {
PaymentMethod string `json:"payment_method"`
}
+func getSubscriptionEpayMoney(priceAmount float64) float64 {
+ return decimal.NewFromFloat(priceAmount).Mul(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64()
+}
+
func SubscriptionRequestEpay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
@@ -84,10 +89,11 @@ func SubscriptionRequestEpay(c *gin.Context) {
return
}
+ paymentMoney := getSubscriptionEpayMoney(plan.PriceAmount)
order := &model.SubscriptionOrder{
UserId: userId,
PlanId: plan.Id,
- Money: plan.PriceAmount,
+ Money: paymentMoney,
TradeNo: tradeNo,
PaymentMethod: req.PaymentMethod,
PaymentProvider: model.PaymentProviderEpay,
@@ -102,7 +108,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
Type: req.PaymentMethod,
ServiceTradeNo: tradeNo,
Name: fmt.Sprintf("SUB:%s", plan.Title),
- Money: strconv.FormatFloat(plan.PriceAmount, 'f', 2, 64),
+ Money: strconv.FormatFloat(paymentMoney, 'f', 2, 64),
Device: epay.PC,
NotifyUrl: notifyUrl,
ReturnUrl: returnUrl,
diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go
index 2beceb426f8d..def884c92f70 100644
--- a/controller/uptime_kuma.go
+++ b/controller/uptime_kuma.go
@@ -9,6 +9,7 @@ import (
"strings"
"time"
+ "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/gin-gonic/gin"
@@ -129,13 +130,17 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
}
func GetUptimeKumaStatus(c *gin.Context) {
+ resp := buildUptimeKumaStatusResponse(c.Request.Context())
+ c.JSON(http.StatusOK, service.TranslateAPIResponse(c, "uptime_status", resp, uptimeTranslationPaths))
+}
+
+func buildUptimeKumaStatusResponse(parent context.Context) gin.H {
groups := console_setting.GetUptimeKumaGroups()
if len(groups) == 0 {
- c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": []UptimeGroupResult{}})
- return
+ return gin.H{"success": true, "message": "", "data": []UptimeGroupResult{}}
}
- ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
+ ctx, cancel := context.WithTimeout(parent, requestTimeout)
defer cancel()
client := &http.Client{Timeout: httpTimeout}
@@ -151,5 +156,5 @@ func GetUptimeKumaStatus(c *gin.Context) {
}
g.Wait()
- c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
+ return gin.H{"success": true, "message": "", "data": results}
}
diff --git a/controller/user.go b/controller/user.go
index 555ef9b31238..29ff3320fb40 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -778,12 +778,13 @@ func DeleteUser(c *gin.Context) {
}
err = model.HardDeleteUserById(id)
if err != nil {
- c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "",
- })
+ common.ApiError(c, err)
return
}
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ })
}
func DeleteSelf(c *gin.Context) {
diff --git a/main.go b/main.go
index 3361b8ce9338..712f38bdb791 100644
--- a/main.go
+++ b/main.go
@@ -258,7 +258,7 @@ func InjectGoogleAnalytics() {
func InitResources() error {
// Initialize resources here if needed
// This is a placeholder function for future resource initialization
- err := godotenv.Load(".env")
+ err := godotenv.Overload(".env")
if err != nil {
if common.DebugEnabled {
common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.")
@@ -284,7 +284,12 @@ func InitResources() error {
return err
}
- model.CheckSetup()
+ if model.SkipDBMigration() {
+ common.SysLog("database schema validation skipped by SKIP_DB_MIGRATION")
+ model.LoadSetupStatus()
+ } else {
+ model.CheckSetup()
+ }
// Initialize options, should after model.InitDB()
model.InitOptionMap()
diff --git a/middleware/model-concurrency-limit.go b/middleware/model-concurrency-limit.go
new file mode 100644
index 000000000000..9003674e1d45
--- /dev/null
+++ b/middleware/model-concurrency-limit.go
@@ -0,0 +1,138 @@
+package middleware
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sync"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/setting"
+ "github.com/gin-gonic/gin"
+)
+
+const modelRequestConcurrencyLimitMark = "MRCL"
+
+var modelRequestConcurrencyStore = struct {
+ sync.Mutex
+ counts map[string]int
+}{
+ counts: map[string]int{},
+}
+
+func modelRequestConcurrencyKey(userId int) string {
+ return fmt.Sprintf("%s:user:%d", modelRequestConcurrencyLimitMark, userId)
+}
+
+func acquireMemoryModelRequestConcurrency(key string, limit int) bool {
+ if limit <= 0 {
+ return true
+ }
+ modelRequestConcurrencyStore.Lock()
+ defer modelRequestConcurrencyStore.Unlock()
+
+ current := modelRequestConcurrencyStore.counts[key]
+ if current >= limit {
+ return false
+ }
+ modelRequestConcurrencyStore.counts[key] = current + 1
+ return true
+}
+
+func releaseMemoryModelRequestConcurrency(key string, limit int) {
+ if limit <= 0 {
+ return
+ }
+ modelRequestConcurrencyStore.Lock()
+ defer modelRequestConcurrencyStore.Unlock()
+
+ current := modelRequestConcurrencyStore.counts[key]
+ if current <= 1 {
+ delete(modelRequestConcurrencyStore.counts, key)
+ return
+ }
+ modelRequestConcurrencyStore.counts[key] = current - 1
+}
+
+func acquireRedisModelRequestConcurrency(ctx context.Context, key string, limit int) (bool, error) {
+ if limit <= 0 {
+ return true, nil
+ }
+ current, err := common.RDB.Incr(ctx, key).Result()
+ if err != nil {
+ return false, err
+ }
+ _ = common.RDB.Expire(ctx, key, common.RateLimitKeyExpirationDuration).Err()
+ if current > int64(limit) {
+ _ = common.RDB.Decr(ctx, key).Err()
+ return false, nil
+ }
+ return true, nil
+}
+
+func releaseRedisModelRequestConcurrency(ctx context.Context, key string, limit int) {
+ if limit <= 0 {
+ return
+ }
+ current, err := common.RDB.Decr(ctx, key).Result()
+ if err != nil {
+ return
+ }
+ if current <= 0 {
+ _ = common.RDB.Del(ctx, key).Err()
+ return
+ }
+ _ = common.RDB.Expire(ctx, key, common.RateLimitKeyExpirationDuration).Err()
+}
+
+func ModelRequestConcurrencyLimit() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !setting.ModelRequestConcurrencyLimitEnabled {
+ c.Next()
+ return
+ }
+
+ userId := c.GetInt("id")
+ if userId == 0 {
+ abortWithOpenAiMessage(c, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ limit := setting.ModelRequestConcurrencyLimitCount
+ group := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
+ if group == "" {
+ group = common.GetContextKeyString(c, constant.ContextKeyUserGroup)
+ }
+ if groupLimit, found := setting.GetGroupConcurrencyLimit(group); found {
+ limit = groupLimit
+ }
+ if limit <= 0 {
+ c.Next()
+ return
+ }
+
+ key := modelRequestConcurrencyKey(userId)
+ if common.RedisEnabled {
+ ctx := context.Background()
+ allowed, err := acquireRedisModelRequestConcurrency(ctx, key, limit)
+ if err != nil {
+ abortWithOpenAiMessage(c, http.StatusInternalServerError, "concurrency_limit_check_failed")
+ return
+ }
+ if !allowed {
+ abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("You have reached the concurrent request limit: at most %d in-flight requests", limit))
+ return
+ }
+ defer releaseRedisModelRequestConcurrency(ctx, key, limit)
+ } else {
+ if !acquireMemoryModelRequestConcurrency(key, limit) {
+ abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("You have reached the concurrent request limit: at most %d in-flight requests", limit))
+ return
+ }
+ defer releaseMemoryModelRequestConcurrency(key, limit)
+ }
+
+ c.Next()
+ }
+}
diff --git a/model/ability.go b/model/ability.go
index 1d7c53fa5805..305ed86bf735 100644
--- a/model/ability.go
+++ b/model/ability.go
@@ -3,6 +3,7 @@ package model
import (
"errors"
"fmt"
+ "sort"
"strings"
"sync"
@@ -104,6 +105,10 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
}
func GetChannel(group string, model string, retry int) (*Channel, error) {
+ return GetChannelExcluding(group, model, retry, nil)
+}
+
+func GetChannelExcluding(group string, model string, retry int, excludeChannelIds map[int]bool) (*Channel, error) {
var abilities []Ability
var err error = nil
@@ -111,6 +116,9 @@ func GetChannel(group string, model string, retry int) (*Channel, error) {
if err != nil {
return nil, err
}
+ if len(excludeChannelIds) > 0 {
+ channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true)
+ }
if common.UsingSQLite || common.UsingPostgreSQL {
err = channelQuery.Order("weight DESC").Find(&abilities).Error
} else {
@@ -119,6 +127,35 @@ func GetChannel(group string, model string, retry int) (*Channel, error) {
if err != nil {
return nil, err
}
+ if len(excludeChannelIds) > 0 {
+ availableAbilities := make([]Ability, 0, len(abilities))
+ uniquePriorities := make(map[int]bool)
+ for _, ability_ := range abilities {
+ if excludeChannelIds[ability_.ChannelId] {
+ continue
+ }
+ availableAbilities = append(availableAbilities, ability_)
+ uniquePriorities[int(*ability_.Priority)] = true
+ }
+ if len(availableAbilities) == 0 {
+ return nil, nil
+ }
+ priorities := make([]int, 0, len(uniquePriorities))
+ for priority := range uniquePriorities {
+ priorities = append(priorities, priority)
+ }
+ sort.Sort(sort.Reverse(sort.IntSlice(priorities)))
+ if retry >= len(priorities) {
+ retry = len(priorities) - 1
+ }
+ targetPriority := priorities[retry]
+ abilities = abilities[:0]
+ for _, ability_ := range availableAbilities {
+ if int(*ability_.Priority) == targetPriority {
+ abilities = append(abilities, ability_)
+ }
+ }
+ }
channel := Channel{}
if len(abilities) > 0 {
// Randomly choose one
diff --git a/model/billing_statistics.go b/model/billing_statistics.go
new file mode 100644
index 000000000000..7e81b6bfe309
--- /dev/null
+++ b/model/billing_statistics.go
@@ -0,0 +1,636 @@
+package model
+
+import (
+ "database/sql"
+ "errors"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+const (
+ BillingStatsGranularityHour = "hour"
+ BillingStatsGranularityDay = "day"
+ BillingStatsGranularityWeek = "week"
+ BillingStatsGranularityMonth = "month"
+ BillingStatsGranularityYear = "year"
+
+ BillingStatsUSDToCNYRate = 7
+)
+
+type BillingStatisticsQuery struct {
+ StartTimestamp int64
+ EndTimestamp int64
+ Granularity string
+ Username string
+ Page int
+ PageSize int
+}
+
+type BillingStatisticsSummary struct {
+ RechargeAmount float64 `json:"recharge_amount"`
+ SubscriptionAmount float64 `json:"subscription_amount"`
+ TotalAmount float64 `json:"total_amount"`
+ RedundantAmount float64 `json:"redundant_amount"`
+ ConsumeQuota int64 `json:"consume_quota"`
+ ConsumeAmount float64 `json:"consume_amount"`
+}
+
+type BillingStatisticsRow struct {
+ BucketStart int64 `json:"bucket_start"`
+ BucketLabel string `json:"bucket_label"`
+ UserId int `json:"user_id"`
+ Username string `json:"username"`
+ RechargeAmount float64 `json:"recharge_amount"`
+ SubscriptionAmount float64 `json:"subscription_amount"`
+ TotalAmount float64 `json:"total_amount"`
+ RedundantAmount float64 `json:"redundant_amount"`
+ ConsumeQuota int64 `json:"consume_quota"`
+ ConsumeAmount float64 `json:"consume_amount"`
+}
+
+type BillingStatisticsUserRow struct {
+ UserId int `json:"user_id"`
+ Username string `json:"username"`
+ RechargeAmount float64 `json:"recharge_amount"`
+ SubscriptionAmount float64 `json:"subscription_amount"`
+ TotalAmount float64 `json:"total_amount"`
+ RedundantAmount float64 `json:"redundant_amount"`
+ ConsumeQuota int64 `json:"consume_quota"`
+ ConsumeAmount float64 `json:"consume_amount"`
+}
+
+type BillingStatisticsResult struct {
+ StartTimestamp int64 `json:"start_timestamp"`
+ EndTimestamp int64 `json:"end_timestamp"`
+ Granularity string `json:"granularity"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ TotalPages int `json:"total_pages"`
+ UserItemsTotal int `json:"user_items_total"`
+ Summary BillingStatisticsSummary `json:"summary"`
+ Items []BillingStatisticsRow `json:"items"`
+ UserItems []BillingStatisticsUserRow `json:"user_items"`
+}
+
+type billingStatsAggregate struct {
+ UserId int
+ Username string
+ RechargeAmount float64
+ SubscriptionAmount float64
+ ConsumeQuota int64
+}
+
+type billingRechargeStatsRow struct {
+ UserId int
+ RechargeAmount float64
+ SubscriptionAmount float64
+}
+
+type billingConsumeStatsRow struct {
+ UserId int
+ Username string
+ ConsumeQuota int64
+}
+
+type billingStatsBucketRange struct {
+ Start int64
+ End int64
+ Label string
+}
+
+func GetBillingStatistics(query BillingStatisticsQuery) (*BillingStatisticsResult, error) {
+ query.Granularity = normalizeBillingStatsGranularity(query.Granularity)
+ query.Page, query.PageSize = normalizeBillingStatsPagination(query.Page, query.PageSize)
+ if query.StartTimestamp <= 0 || query.EndTimestamp <= 0 {
+ start, end := defaultBillingStatsRange()
+ if query.StartTimestamp <= 0 {
+ query.StartTimestamp = start
+ }
+ if query.EndTimestamp <= 0 {
+ query.EndTimestamp = end
+ }
+ }
+ if query.EndTimestamp <= query.StartTimestamp {
+ return nil, errors.New("end_timestamp must be greater than start_timestamp")
+ }
+
+ userIds, userNames, err := billingStatsUsers(query.Username)
+ if err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(query.Username) != "" && len(userIds) == 0 {
+ return &BillingStatisticsResult{
+ StartTimestamp: query.StartTimestamp,
+ EndTimestamp: query.EndTimestamp,
+ Granularity: query.Granularity,
+ Page: query.Page,
+ PageSize: query.PageSize,
+ TotalPages: 0,
+ UserItemsTotal: 0,
+ Summary: BillingStatisticsSummary{},
+ Items: []BillingStatisticsRow{},
+ UserItems: []BillingStatisticsUserRow{},
+ }, nil
+ }
+
+ aggregates := map[int]*billingStatsAggregate{}
+ if err := addRechargeBillingStats(query, userIds, userNames, aggregates); err != nil {
+ return nil, err
+ }
+ if err := addConsumeBillingStats(query, userIds, userNames, aggregates); err != nil {
+ return nil, err
+ }
+ if err := fillBillingStatsAggregateUsernames(aggregates, userNames); err != nil {
+ return nil, err
+ }
+ items, err := getBillingStatsChartItems(query, userIds)
+ if err != nil {
+ return nil, err
+ }
+
+ userAggregates := make(map[int]*BillingStatisticsUserRow)
+ summary := BillingStatisticsSummary{}
+ for _, agg := range aggregates {
+ row := BillingStatisticsUserRow{
+ UserId: agg.UserId,
+ Username: agg.Username,
+ RechargeAmount: agg.RechargeAmount,
+ SubscriptionAmount: agg.SubscriptionAmount,
+ TotalAmount: agg.RechargeAmount + agg.SubscriptionAmount,
+ ConsumeQuota: agg.ConsumeQuota,
+ ConsumeAmount: quotaToBillingAmount(agg.ConsumeQuota),
+ }
+ row.RedundantAmount = row.TotalAmount - row.ConsumeAmount
+ summary.RechargeAmount += row.RechargeAmount
+ summary.SubscriptionAmount += row.SubscriptionAmount
+ summary.ConsumeQuota += row.ConsumeQuota
+ userAggregates[row.UserId] = &row
+ }
+ summary.TotalAmount = summary.RechargeAmount + summary.SubscriptionAmount
+ summary.ConsumeAmount = quotaToBillingAmount(summary.ConsumeQuota)
+ summary.RedundantAmount = summary.TotalAmount - summary.ConsumeAmount
+ userItems := make([]BillingStatisticsUserRow, 0, len(userAggregates))
+ for _, row := range userAggregates {
+ row.TotalAmount = row.RechargeAmount + row.SubscriptionAmount
+ row.ConsumeAmount = quotaToBillingAmount(row.ConsumeQuota)
+ row.RedundantAmount = row.TotalAmount - row.ConsumeAmount
+ userItems = append(userItems, *row)
+ }
+
+ sort.Slice(userItems, func(i, j int) bool {
+ leftTotal := userItems[i].RechargeAmount + userItems[i].SubscriptionAmount + userItems[i].ConsumeAmount
+ rightTotal := userItems[j].RechargeAmount + userItems[j].SubscriptionAmount + userItems[j].ConsumeAmount
+ if leftTotal == rightTotal {
+ return userItems[i].Username < userItems[j].Username
+ }
+ return leftTotal > rightTotal
+ })
+ userItemsTotal := len(userItems)
+ totalPages := 0
+ if userItemsTotal > 0 {
+ totalPages = (userItemsTotal + query.PageSize - 1) / query.PageSize
+ if query.Page > totalPages {
+ query.Page = totalPages
+ }
+ }
+ startIdx := (query.Page - 1) * query.PageSize
+ if startIdx > userItemsTotal {
+ startIdx = userItemsTotal
+ }
+ endIdx := startIdx + query.PageSize
+ if endIdx > userItemsTotal {
+ endIdx = userItemsTotal
+ }
+ pagedUserItems := userItems[startIdx:endIdx]
+
+ return &BillingStatisticsResult{
+ StartTimestamp: query.StartTimestamp,
+ EndTimestamp: query.EndTimestamp,
+ Granularity: query.Granularity,
+ Page: query.Page,
+ PageSize: query.PageSize,
+ TotalPages: totalPages,
+ UserItemsTotal: userItemsTotal,
+ Summary: summary,
+ Items: items,
+ UserItems: pagedUserItems,
+ }, nil
+}
+
+func normalizeBillingStatsGranularity(granularity string) string {
+ switch strings.ToLower(strings.TrimSpace(granularity)) {
+ case BillingStatsGranularityDay, BillingStatsGranularityWeek, BillingStatsGranularityMonth, BillingStatsGranularityYear:
+ return strings.ToLower(strings.TrimSpace(granularity))
+ default:
+ return BillingStatsGranularityDay
+ }
+}
+
+func normalizeBillingStatsPagination(page int, pageSize int) (int, int) {
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 {
+ pageSize = common.ItemsPerPage
+ }
+ if pageSize > 100 {
+ pageSize = 100
+ }
+ return page, pageSize
+}
+
+func defaultBillingStatsRange() (int64, int64) {
+ now := time.Now()
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ return start.Unix(), start.AddDate(0, 0, 1).Unix()
+}
+
+func billingStatsUsers(username string) ([]int, map[int]string, error) {
+ username = strings.TrimSpace(username)
+ if username == "" {
+ return nil, map[int]string{}, nil
+ }
+ var users []User
+ if err := DB.Model(&User{}).
+ Select("id, username").
+ Where("username = ?", username).
+ Find(&users).Error; err != nil {
+ return nil, nil, err
+ }
+ userIds := make([]int, 0, len(users))
+ userNames := make(map[int]string, len(users))
+ for _, user := range users {
+ userIds = append(userIds, user.Id)
+ userNames[user.Id] = user.Username
+ }
+ return userIds, userNames, nil
+}
+
+func addRechargeBillingStats(query BillingStatisticsQuery, userIds []int, userNames map[int]string, aggregates map[int]*billingStatsAggregate) error {
+ var rows []billingRechargeStatsRow
+ tx := DB.Model(&TopUp{}).
+ Select(
+ "user_id, COALESCE(SUM(CASE WHEN amount = 0 THEN money ELSE 0 END), 0) AS subscription_amount, COALESCE(SUM(CASE WHEN amount <> 0 THEN money ELSE 0 END), 0) AS recharge_amount",
+ ).
+ Where(
+ "status = ? AND ((complete_time > 0 AND complete_time >= ? AND complete_time < ?) OR (complete_time = 0 AND create_time >= ? AND create_time < ?))",
+ common.TopUpStatusSuccess,
+ query.StartTimestamp,
+ query.EndTimestamp,
+ query.StartTimestamp,
+ query.EndTimestamp,
+ )
+ if len(userIds) > 0 {
+ tx = tx.Where("user_id IN ?", userIds)
+ }
+ if err := tx.Group("user_id").Scan(&rows).Error; err != nil {
+ return err
+ }
+
+ for _, row := range rows {
+ agg := getBillingStatsAggregate(userNames, aggregates, row.UserId)
+ agg.RechargeAmount += row.RechargeAmount
+ agg.SubscriptionAmount += row.SubscriptionAmount
+ }
+ return nil
+}
+
+func addConsumeBillingStats(query BillingStatisticsQuery, userIds []int, userNames map[int]string, aggregates map[int]*billingStatsAggregate) error {
+ var rows []billingConsumeStatsRow
+ tx := LOG_DB.Model(&Log{}).
+ Select("user_id, MAX(username) AS username, COALESCE(SUM(quota), 0) AS consume_quota").
+ Where("type = ? AND created_at >= ? AND created_at < ?", LogTypeConsume, query.StartTimestamp, query.EndTimestamp)
+ if len(userIds) > 0 {
+ tx = tx.Where("user_id IN ?", userIds)
+ }
+ if err := tx.Group("user_id").Scan(&rows).Error; err != nil {
+ return err
+ }
+
+ for _, row := range rows {
+ if row.Username != "" {
+ userNames[row.UserId] = row.Username
+ }
+ agg := getBillingStatsAggregate(userNames, aggregates, row.UserId)
+ agg.ConsumeQuota += row.ConsumeQuota
+ }
+ return nil
+}
+
+func getBillingStatsChartItems(query BillingStatisticsQuery, userIds []int) ([]BillingStatisticsRow, error) {
+ buckets := billingStatsBucketRanges(query.StartTimestamp, query.EndTimestamp, query.Granularity)
+ items := make([]BillingStatisticsRow, 0, len(buckets))
+ itemByBucket := make(map[int64]*BillingStatisticsRow, len(buckets))
+ for _, bucket := range buckets {
+ row := &BillingStatisticsRow{
+ BucketStart: bucket.Start,
+ BucketLabel: bucket.Label,
+ }
+ itemByBucket[bucket.Start] = row
+ items = append(items, *row)
+ }
+
+ if err := addBillingStatsChartRechargeItems(query, userIds, buckets, itemByBucket); err != nil {
+ return nil, err
+ }
+ if err := addBillingStatsChartConsumeItems(query, userIds, buckets, itemByBucket); err != nil {
+ return nil, err
+ }
+
+ for index := range items {
+ if row := itemByBucket[items[index].BucketStart]; row != nil {
+ row.TotalAmount = row.RechargeAmount + row.SubscriptionAmount
+ row.ConsumeAmount = quotaToBillingAmount(row.ConsumeQuota)
+ row.RedundantAmount = row.TotalAmount - row.ConsumeAmount
+ items[index] = *row
+ }
+ }
+
+ return items, nil
+}
+
+func addBillingStatsChartRechargeItems(query BillingStatisticsQuery, userIds []int, buckets []billingStatsBucketRange, itemByBucket map[int64]*BillingStatisticsRow) error {
+ if len(buckets) == 0 {
+ return nil
+ }
+ selectSQL, selectArgs := billingStatsRechargeBucketSelectSQL(buckets)
+ tx := DB.Model(&TopUp{}).
+ Select(selectSQL, selectArgs...).
+ Where(
+ "status = ? AND ((complete_time > 0 AND complete_time >= ? AND complete_time < ?) OR (complete_time = 0 AND create_time >= ? AND create_time < ?))",
+ common.TopUpStatusSuccess,
+ query.StartTimestamp,
+ query.EndTimestamp,
+ query.StartTimestamp,
+ query.EndTimestamp,
+ )
+ if len(userIds) > 0 {
+ tx = tx.Where("user_id IN ?", userIds)
+ }
+ rows, err := tx.Rows()
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ if !rows.Next() {
+ return nil
+ }
+
+ values := make([]sql.NullFloat64, len(buckets)*2)
+ scanArgs := make([]any, len(values))
+ for i := range values {
+ scanArgs[i] = &values[i]
+ }
+ if err := rows.Scan(scanArgs...); err != nil {
+ return err
+ }
+ for index, bucket := range buckets {
+ if item := itemByBucket[bucket.Start]; item != nil {
+ item.SubscriptionAmount = values[index*2].Float64
+ item.RechargeAmount = values[index*2+1].Float64
+ }
+ }
+ return nil
+}
+
+func addBillingStatsChartConsumeItems(query BillingStatisticsQuery, userIds []int, buckets []billingStatsBucketRange, itemByBucket map[int64]*BillingStatisticsRow) error {
+ if len(buckets) == 0 {
+ return nil
+ }
+ selectSQL, selectArgs := billingStatsConsumeBucketSelectSQL(buckets)
+ tx := LOG_DB.Model(&Log{}).
+ Select(selectSQL, selectArgs...).
+ Where("type = ? AND created_at >= ? AND created_at < ?", LogTypeConsume, query.StartTimestamp, query.EndTimestamp)
+ if len(userIds) > 0 {
+ tx = tx.Where("user_id IN ?", userIds)
+ }
+ rows, err := tx.Rows()
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ if !rows.Next() {
+ return nil
+ }
+
+ values := make([]sql.NullInt64, len(buckets))
+ scanArgs := make([]any, len(values))
+ for i := range values {
+ scanArgs[i] = &values[i]
+ }
+ if err := rows.Scan(scanArgs...); err != nil {
+ return err
+ }
+ for index, bucket := range buckets {
+ if item := itemByBucket[bucket.Start]; item != nil {
+ item.ConsumeQuota = values[index].Int64
+ }
+ }
+ return nil
+}
+
+func billingStatsRechargeBucketSelectSQL(buckets []billingStatsBucketRange) (string, []any) {
+ parts := make([]string, 0, len(buckets)*2)
+ args := make([]any, 0, len(buckets)*10)
+ for index, bucket := range buckets {
+ condition := "((complete_time > 0 AND complete_time >= ? AND complete_time < ?) OR (complete_time = 0 AND create_time >= ? AND create_time < ?))"
+ parts = append(parts,
+ "COALESCE(SUM(CASE WHEN "+condition+" AND amount = 0 THEN money ELSE 0 END), 0) AS subscription_amount_"+strconv.Itoa(index),
+ "COALESCE(SUM(CASE WHEN "+condition+" AND amount <> 0 THEN money ELSE 0 END), 0) AS recharge_amount_"+strconv.Itoa(index),
+ )
+ args = append(args, bucket.Start, bucket.End, bucket.Start, bucket.End)
+ args = append(args, bucket.Start, bucket.End, bucket.Start, bucket.End)
+ }
+ return strings.Join(parts, ", "), args
+}
+
+func billingStatsConsumeBucketSelectSQL(buckets []billingStatsBucketRange) (string, []any) {
+ parts := make([]string, 0, len(buckets))
+ args := make([]any, 0, len(buckets)*2)
+ for index, bucket := range buckets {
+ parts = append(parts, "COALESCE(SUM(CASE WHEN created_at >= ? AND created_at < ? THEN quota ELSE 0 END), 0) AS consume_quota_"+strconv.Itoa(index))
+ args = append(args, bucket.Start, bucket.End)
+ }
+ return strings.Join(parts, ", "), args
+}
+
+func getBillingStatsAggregate(userNames map[int]string, aggregates map[int]*billingStatsAggregate, userId int) *billingStatsAggregate {
+ if agg, ok := aggregates[userId]; ok {
+ return agg
+ }
+ username := userNames[userId]
+ agg := &billingStatsAggregate{
+ UserId: userId,
+ Username: username,
+ }
+ aggregates[userId] = agg
+ return agg
+}
+
+func fillBillingStatsAggregateUsernames(aggregates map[int]*billingStatsAggregate, userNames map[int]string) error {
+ missingUserIds := make([]int, 0)
+ for userId, agg := range aggregates {
+ if userId <= 0 || agg.Username != "" {
+ continue
+ }
+ if username := userNames[userId]; username != "" {
+ agg.Username = username
+ continue
+ }
+ missingUserIds = append(missingUserIds, userId)
+ }
+ if len(missingUserIds) == 0 {
+ return nil
+ }
+
+ var users []User
+ if err := DB.Model(&User{}).
+ Select("id, username").
+ Where("id IN ?", missingUserIds).
+ Find(&users).Error; err != nil {
+ return err
+ }
+ for _, user := range users {
+ userNames[user.Id] = user.Username
+ if agg := aggregates[user.Id]; agg != nil && agg.Username == "" {
+ agg.Username = user.Username
+ }
+ }
+ return nil
+}
+
+func billingStatsBucketRanges(startTimestamp int64, endTimestamp int64, granularity string) []billingStatsBucketRange {
+ if endTimestamp <= startTimestamp {
+ return []billingStatsBucketRange{}
+ }
+ start := billingStatsBucketStart(time.Unix(startTimestamp, 0), granularity)
+ end := time.Unix(endTimestamp, 0)
+ ranges := make([]billingStatsBucketRange, 0)
+ for current := start; current.Unix() < endTimestamp; current = billingStatsNextBucketStart(current, granularity) {
+ next := billingStatsNextBucketStart(current, granularity)
+ bucketEnd := next
+ if bucketEnd.After(end) {
+ bucketEnd = end
+ }
+ if bucketEnd.Unix() <= startTimestamp {
+ continue
+ }
+ ranges = append(ranges, billingStatsBucketRange{
+ Start: current.Unix(),
+ End: bucketEnd.Unix(),
+ Label: billingStatsBucketLabel(current, granularity),
+ })
+ }
+ return ranges
+}
+
+func billingStatsBucketStart(t time.Time, granularity string) time.Time {
+ switch granularity {
+ case BillingStatsGranularityMonth:
+ return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
+ case BillingStatsGranularityYear:
+ return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, t.Location())
+ default:
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+ }
+}
+
+func billingStatsNextBucketStart(t time.Time, granularity string) time.Time {
+ switch granularity {
+ case BillingStatsGranularityMonth:
+ return t.AddDate(0, 1, 0)
+ case BillingStatsGranularityYear:
+ return t.AddDate(1, 0, 0)
+ default:
+ return t.AddDate(0, 0, 1)
+ }
+}
+
+func billingStatsBucketLabel(t time.Time, granularity string) string {
+ switch granularity {
+ case BillingStatsGranularityMonth:
+ return t.Format("2006-01")
+ case BillingStatsGranularityYear:
+ return t.Format("2006")
+ default:
+ return t.Format("2006-01-02")
+ }
+}
+
+func billingStatsBucketSQL(timestampExpr string, granularity string) string {
+ switch {
+ case common.UsingPostgreSQL:
+ return billingStatsPostgresBucketSQL(timestampExpr, granularity)
+ case common.UsingMySQL:
+ return billingStatsMySQLBucketSQL(timestampExpr, granularity)
+ default:
+ return billingStatsSQLiteBucketSQL(timestampExpr, granularity)
+ }
+}
+
+func billingStatsPostgresBucketSQL(timestampExpr string, granularity string) string {
+ switch granularity {
+ case BillingStatsGranularityMonth:
+ return "CAST(EXTRACT(EPOCH FROM date_trunc('month', to_timestamp(" + timestampExpr + "))) AS BIGINT)"
+ case BillingStatsGranularityYear:
+ return "CAST(EXTRACT(EPOCH FROM date_trunc('year', to_timestamp(" + timestampExpr + "))) AS BIGINT)"
+ default:
+ return "CAST(EXTRACT(EPOCH FROM date_trunc('day', to_timestamp(" + timestampExpr + "))) AS BIGINT)"
+ }
+}
+
+func billingStatsMySQLBucketSQL(timestampExpr string, granularity string) string {
+ switch granularity {
+ case BillingStatsGranularityMonth:
+ return "UNIX_TIMESTAMP(DATE_FORMAT(FROM_UNIXTIME(" + timestampExpr + "), '%Y-%m-01 00:00:00'))"
+ case BillingStatsGranularityYear:
+ return "UNIX_TIMESTAMP(DATE_FORMAT(FROM_UNIXTIME(" + timestampExpr + "), '%Y-01-01 00:00:00'))"
+ default:
+ return "UNIX_TIMESTAMP(DATE(FROM_UNIXTIME(" + timestampExpr + ")))"
+ }
+}
+
+func billingStatsSQLiteBucketSQL(timestampExpr string, granularity string) string {
+ switch granularity {
+ case BillingStatsGranularityMonth:
+ return "CAST(strftime('%s', datetime(" + timestampExpr + ", 'unixepoch', 'localtime', 'start of month', 'utc')) AS INTEGER)"
+ case BillingStatsGranularityYear:
+ return "CAST(strftime('%s', datetime(" + timestampExpr + ", 'unixepoch', 'localtime', 'start of year', 'utc')) AS INTEGER)"
+ default:
+ return "CAST(strftime('%s', datetime(" + timestampExpr + ", 'unixepoch', 'localtime', 'start of day', 'utc')) AS INTEGER)"
+ }
+}
+
+func billingStatsBucket(timestamp int64, granularity string) (int64, string) {
+ t := time.Unix(timestamp, 0)
+ switch granularity {
+ case BillingStatsGranularityDay:
+ start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+ return start.Unix(), start.Format("2006-01-02")
+ case BillingStatsGranularityWeek:
+ weekday := int(t.Weekday())
+ if weekday == 0 {
+ weekday = 7
+ }
+ startDay := t.AddDate(0, 0, 1-weekday)
+ start := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), 0, 0, 0, 0, t.Location())
+ return start.Unix(), start.Format("2006-01-02")
+ case BillingStatsGranularityMonth:
+ start := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
+ return start.Unix(), start.Format("2006-01")
+ default:
+ start := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location())
+ return start.Unix(), start.Format("2006-01-02 15:00")
+ }
+}
+
+func quotaToBillingAmount(quota int64) float64 {
+ if common.QuotaPerUnit <= 0 {
+ return 0
+ }
+ return float64(quota) / common.QuotaPerUnit * BillingStatsUSDToCNYRate
+}
diff --git a/model/channel_cache.go b/model/channel_cache.go
index c9c503576038..9f41c6eccc93 100644
--- a/model/channel_cache.go
+++ b/model/channel_cache.go
@@ -94,9 +94,13 @@ func SyncChannelCache(frequency int) {
}
func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
+ return GetRandomSatisfiedChannelExcluding(group, model, retry, nil)
+}
+
+func GetRandomSatisfiedChannelExcluding(group string, model string, retry int, excludeChannelIds map[int]bool) (*Channel, error) {
// if memory cache is disabled, get channel directly from database
if !common.MemoryCacheEnabled {
- return GetChannel(group, model, retry)
+ return GetChannelExcluding(group, model, retry, excludeChannelIds)
}
channelSyncLock.RLock()
@@ -115,15 +119,27 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
return nil, nil
}
- if len(channels) == 1 {
- if channel, ok := channelsIDM[channels[0]]; ok {
+ availableChannels := make([]int, 0, len(channels))
+ for _, channelId := range channels {
+ if excludeChannelIds != nil && excludeChannelIds[channelId] {
+ continue
+ }
+ availableChannels = append(availableChannels, channelId)
+ }
+
+ if len(availableChannels) == 0 {
+ return nil, nil
+ }
+
+ if len(availableChannels) == 1 {
+ if channel, ok := channelsIDM[availableChannels[0]]; ok {
return channel, nil
}
- return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0])
+ return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", availableChannels[0])
}
uniquePriorities := make(map[int]bool)
- for _, channelId := range channels {
+ for _, channelId := range availableChannels {
if channel, ok := channelsIDM[channelId]; ok {
uniquePriorities[int(channel.GetPriority())] = true
} else {
@@ -144,7 +160,7 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
// get the priority for the given retry number
var sumWeight = 0
var targetChannels []*Channel
- for _, channelId := range channels {
+ for _, channelId := range availableChannels {
if channel, ok := channelsIDM[channelId]; ok {
if channel.GetPriority() == targetPriority {
sumWeight += channel.GetWeight()
diff --git a/model/log.go b/model/log.go
index 8ec7807e0339..2cec7e8bf452 100644
--- a/model/log.go
+++ b/model/log.go
@@ -36,8 +36,11 @@ type Log struct {
TokenId int `json:"token_id" gorm:"default:0;index"`
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
- RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
- UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
+ RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
+ // Keep the API field for compatibility, but do not read/write a DB column.
+ // Some existing deployments do not have logs.upstream_request_id and should
+ // not be forced to alter the logs table.
+ UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"-"`
Other string `json:"other"`
}
@@ -149,15 +152,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
- upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
otherStr := common.MapToJsonStr(other)
- // 判断是否需要记录 IP
- needRecordIp := false
- if settingMap, err := GetUserSetting(userId, false); err == nil {
- if settingMap.RecordIpLog {
- needRecordIp = true
- }
- }
log := &Log{
UserId: userId,
Username: username,
@@ -174,15 +169,9 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
UseTime: useTimeSeconds,
IsStream: isStream,
Group: group,
- Ip: func() string {
- if needRecordIp {
- return c.ClientIP()
- }
- return ""
- }(),
- RequestId: requestId,
- UpstreamRequestId: upstreamRequestId,
- Other: otherStr,
+ Ip: c.ClientIP(),
+ RequestId: requestId,
+ Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -212,15 +201,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
- upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
otherStr := common.MapToJsonStr(params.Other)
- // 判断是否需要记录 IP
- needRecordIp := false
- if settingMap, err := GetUserSetting(userId, false); err == nil {
- if settingMap.RecordIpLog {
- needRecordIp = true
- }
- }
log := &Log{
UserId: userId,
Username: username,
@@ -237,15 +218,9 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
UseTime: params.UseTimeSeconds,
IsStream: params.IsStream,
Group: params.Group,
- Ip: func() string {
- if needRecordIp {
- return c.ClientIP()
- }
- return ""
- }(),
- RequestId: requestId,
- UpstreamRequestId: upstreamRequestId,
- Other: otherStr,
+ Ip: c.ClientIP(),
+ RequestId: requestId,
+ Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -315,9 +290,6 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
- if upstreamRequestId != "" {
- tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
- }
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}
@@ -397,9 +369,6 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
- if upstreamRequestId != "" {
- tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
- }
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}
diff --git a/model/main.go b/model/main.go
index 16cd373fb203..e7cac62b9877 100644
--- a/model/main.go
+++ b/model/main.go
@@ -89,19 +89,31 @@ func createRootAccountIfNeed() error {
}
func CheckSetup() {
+ checkSetup(true)
+}
+
+func LoadSetupStatus() {
+ checkSetup(false)
+}
+
+func checkSetup(createMissingSetup bool) {
setup := GetSetup()
if setup == nil {
// No setup record exists, check if we have a root user
if RootUserExists() {
- common.SysLog("system is not initialized, but root user exists")
- // Create setup record
- newSetup := Setup{
- Version: common.Version,
- InitializedAt: time.Now().Unix(),
- }
- err := DB.Create(&newSetup).Error
- if err != nil {
- common.SysLog("failed to create setup record: " + err.Error())
+ if createMissingSetup {
+ common.SysLog("system is not initialized, but root user exists")
+ // Create setup record
+ newSetup := Setup{
+ Version: common.Version,
+ InitializedAt: time.Now().Unix(),
+ }
+ err := DB.Create(&newSetup).Error
+ if err != nil {
+ common.SysLog("failed to create setup record: " + err.Error())
+ }
+ } else {
+ common.SysLog("setup record not found, treating existing root user as initialized")
}
constant.Setup = true
} else {
@@ -174,7 +186,24 @@ func chooseDB(envName string, isLog bool) (*gorm.DB, error) {
})
}
+func SkipDBMigration() bool {
+ value := strings.TrimSpace(os.Getenv("SKIP_DB_MIGRATION"))
+ if value == "" {
+ return false
+ }
+ switch strings.ToLower(value) {
+ case "1", "t", "true", "y", "yes", "on":
+ return true
+ case "0", "f", "false", "n", "no", "off":
+ return false
+ default:
+ common.SysError(fmt.Sprintf("failed to parse SKIP_DB_MIGRATION: %s, using default value: false", value))
+ return false
+ }
+}
+
func InitDB() (err error) {
+ skipMigration := SkipDBMigration()
db, err := chooseDB("SQL_DSN", false)
if err == nil {
if common.DebugEnabled {
@@ -195,6 +224,11 @@ func InitDB() (err error) {
sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000))
sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60)))
+ if skipMigration {
+ common.SysLog("database migration skipped by SKIP_DB_MIGRATION")
+ return nil
+ }
+
if !common.IsMasterNode {
return nil
}
@@ -215,6 +249,7 @@ func InitLogDB() (err error) {
LOG_DB = DB
return
}
+ skipMigration := SkipDBMigration()
db, err := chooseDB("LOG_SQL_DSN", true)
if err == nil {
if common.DebugEnabled {
@@ -235,6 +270,11 @@ func InitLogDB() (err error) {
sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000))
sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60)))
+ if skipMigration {
+ common.SysLog("log database migration skipped by SKIP_DB_MIGRATION")
+ return nil
+ }
+
if !common.IsMasterNode {
return nil
}
diff --git a/model/option.go b/model/option.go
index e0a3048d34f2..8f4c0aebacc5 100644
--- a/model/option.go
+++ b/model/option.go
@@ -26,7 +26,7 @@ func AllOption() ([]*Option, error) {
return options, err
}
-func InitOptionMap() {
+func InitOptionMap(loadFromDatabase ...bool) {
common.OptionMapRWMutex.Lock()
common.OptionMap = make(map[string]string)
@@ -52,6 +52,16 @@ func InitOptionMap() {
common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled)
common.OptionMap["TaskEnabled"] = strconv.FormatBool(common.TaskEnabled)
common.OptionMap["DataExportEnabled"] = strconv.FormatBool(common.DataExportEnabled)
+ common.OptionMap["RankingsDisplayMultiplier"] = "1"
+ common.OptionMap["RankingsDisplayJitterRatio"] = "0"
+ common.OptionMap["AITranslationEnabled"] = "false"
+ common.OptionMap["AITranslationBaseURL"] = "https://api.openai.com/v1"
+ common.OptionMap["AITranslationAPIKey"] = ""
+ common.OptionMap["AITranslationModel"] = "gpt-4o-mini"
+ common.OptionMap["AITranslationCacheSeconds"] = "604800"
+ common.OptionMap["AITranslationTimeoutSeconds"] = "30"
+ common.OptionMap["AITranslationInitialWaitSeconds"] = "3"
+ common.OptionMap["AITranslationSnapshot"] = ""
common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64)
common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled)
common.OptionMap["EmailAliasRestrictionEnabled"] = strconv.FormatBool(common.EmailAliasRestrictionEnabled)
@@ -141,6 +151,8 @@ func InitOptionMap() {
common.OptionMap["ModelRequestRateLimitDurationMinutes"] = strconv.Itoa(setting.ModelRequestRateLimitDurationMinutes)
common.OptionMap["ModelRequestRateLimitSuccessCount"] = strconv.Itoa(setting.ModelRequestRateLimitSuccessCount)
common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString()
+ common.OptionMap["ModelRequestConcurrencyLimitCount"] = strconv.Itoa(setting.ModelRequestConcurrencyLimitCount)
+ common.OptionMap["ModelRequestConcurrencyLimitGroup"] = setting.ModelRequestConcurrencyLimitGroup2JSONString()
common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString()
common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString()
common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString()
@@ -166,9 +178,17 @@ func InitOptionMap() {
common.OptionMap["MjForwardUrlEnabled"] = strconv.FormatBool(setting.MjForwardUrlEnabled)
common.OptionMap["MjActionCheckSuccessEnabled"] = strconv.FormatBool(setting.MjActionCheckSuccessEnabled)
common.OptionMap["CheckSensitiveEnabled"] = strconv.FormatBool(setting.CheckSensitiveEnabled)
+ common.OptionMap["ModerationEnabled"] = strconv.FormatBool(setting.ModerationEnabled)
+ common.OptionMap["ModerationModel"] = setting.ModerationModel
+ common.OptionMap["ModerationBaseURL"] = setting.ModerationBaseURL
+ common.OptionMap["ModerationAPIKey"] = setting.ModerationAPIKey
+ common.OptionMap["ModerationTimeoutSeconds"] = strconv.Itoa(setting.ModerationTimeoutSeconds)
+ common.OptionMap["ModerationFailureMode"] = setting.ModerationFailureMode
+ common.OptionMap["ModerationBlockCategories"] = setting.ModerationBlockCategoriesToString()
common.OptionMap["DemoSiteEnabled"] = strconv.FormatBool(operation_setting.DemoSiteEnabled)
common.OptionMap["SelfUseModeEnabled"] = strconv.FormatBool(operation_setting.SelfUseModeEnabled)
common.OptionMap["ModelRequestRateLimitEnabled"] = strconv.FormatBool(setting.ModelRequestRateLimitEnabled)
+ common.OptionMap["ModelRequestConcurrencyLimitEnabled"] = strconv.FormatBool(setting.ModelRequestConcurrencyLimitEnabled)
common.OptionMap["CheckSensitiveOnPromptEnabled"] = strconv.FormatBool(setting.CheckSensitiveOnPromptEnabled)
common.OptionMap["StopOnSensitiveEnabled"] = strconv.FormatBool(setting.StopOnSensitiveEnabled)
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
@@ -185,7 +205,9 @@ func InitOptionMap() {
}
common.OptionMapRWMutex.Unlock()
- loadOptionsFromDatabase()
+ if len(loadFromDatabase) == 0 || loadFromDatabase[0] {
+ loadOptionsFromDatabase()
+ }
}
func loadOptionsFromDatabase() {
@@ -309,6 +331,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.MjActionCheckSuccessEnabled = boolValue
case "CheckSensitiveEnabled":
setting.CheckSensitiveEnabled = boolValue
+ case "ModerationEnabled":
+ setting.ModerationEnabled = boolValue
case "DemoSiteEnabled":
operation_setting.DemoSiteEnabled = boolValue
case "SelfUseModeEnabled":
@@ -317,6 +341,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.CheckSensitiveOnPromptEnabled = boolValue
case "ModelRequestRateLimitEnabled":
setting.ModelRequestRateLimitEnabled = boolValue
+ case "ModelRequestConcurrencyLimitEnabled":
+ setting.ModelRequestConcurrencyLimitEnabled = boolValue
case "StopOnSensitiveEnabled":
setting.StopOnSensitiveEnabled = boolValue
case "SMTPSSLEnabled":
@@ -493,6 +519,10 @@ func updateOptionMap(key string, value string) (err error) {
setting.ModelRequestRateLimitSuccessCount, _ = strconv.Atoi(value)
case "ModelRequestRateLimitGroup":
err = setting.UpdateModelRequestRateLimitGroupByJSONString(value)
+ case "ModelRequestConcurrencyLimitCount":
+ setting.ModelRequestConcurrencyLimitCount, _ = strconv.Atoi(value)
+ case "ModelRequestConcurrencyLimitGroup":
+ err = setting.UpdateModelRequestConcurrencyLimitGroupByJSONString(value)
case "RetryTimes":
common.RetryTimes, _ = strconv.Atoi(value)
case "DataExportInterval":
@@ -533,6 +563,21 @@ func updateOptionMap(key string, value string) (err error) {
common.QuotaPerUnit, _ = strconv.ParseFloat(value, 64)
case "SensitiveWords":
setting.SensitiveWordsFromString(value)
+ case "ModerationModel":
+ setting.ModerationModel = strings.TrimSpace(value)
+ case "ModerationBaseURL":
+ setting.ModerationBaseURL = strings.TrimRight(strings.TrimSpace(value), "/")
+ case "ModerationAPIKey":
+ setting.ModerationAPIKey = strings.TrimSpace(value)
+ case "ModerationTimeoutSeconds":
+ setting.ModerationTimeoutSeconds, _ = strconv.Atoi(value)
+ if setting.ModerationTimeoutSeconds <= 0 {
+ setting.ModerationTimeoutSeconds = 10
+ }
+ case "ModerationFailureMode":
+ setting.ModerationFailureMode = setting.NormalizeModerationFailureMode(value)
+ case "ModerationBlockCategories":
+ setting.ModerationBlockCategoriesFromString(value)
case "AutomaticDisableKeywords":
operation_setting.AutomaticDisableKeywordsFromString(value)
case "AutomaticDisableStatusCodes":
diff --git a/model/pricing.go b/model/pricing.go
index b9574a388587..3fa93ba37afd 100644
--- a/model/pricing.go
+++ b/model/pricing.go
@@ -16,26 +16,27 @@ import (
)
type Pricing struct {
- ModelName string `json:"model_name"`
- Description string `json:"description,omitempty"`
- Icon string `json:"icon,omitempty"`
- Tags string `json:"tags,omitempty"`
- VendorID int `json:"vendor_id,omitempty"`
- QuotaType int `json:"quota_type"`
- ModelRatio float64 `json:"model_ratio"`
- ModelPrice float64 `json:"model_price"`
- OwnerBy string `json:"owner_by"`
- CompletionRatio float64 `json:"completion_ratio"`
- CacheRatio *float64 `json:"cache_ratio,omitempty"`
- CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"`
- ImageRatio *float64 `json:"image_ratio,omitempty"`
- AudioRatio *float64 `json:"audio_ratio,omitempty"`
- AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
- EnableGroup []string `json:"enable_groups"`
- SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
- BillingMode string `json:"billing_mode,omitempty"`
- BillingExpr string `json:"billing_expr,omitempty"`
- PricingVersion string `json:"pricing_version,omitempty"`
+ ModelName string `json:"model_name"`
+ Description string `json:"description,omitempty"`
+ Icon string `json:"icon,omitempty"`
+ Tags string `json:"tags,omitempty"`
+ VendorID int `json:"vendor_id,omitempty"`
+ QuotaType int `json:"quota_type"`
+ ModelRatio float64 `json:"model_ratio"`
+ ModelPrice float64 `json:"model_price"`
+ OwnerBy string `json:"owner_by"`
+ CompletionRatio float64 `json:"completion_ratio"`
+ CacheRatio *float64 `json:"cache_ratio,omitempty"`
+ CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"`
+ ImageRatio *float64 `json:"image_ratio,omitempty"`
+ AudioRatio *float64 `json:"audio_ratio,omitempty"`
+ AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
+ EnableGroup []string `json:"enable_groups"`
+ SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
+ BillingMode string `json:"billing_mode,omitempty"`
+ BillingExpr string `json:"billing_expr,omitempty"`
+ VideoPrice *billing_setting.VideoPriceConfig `json:"video_price,omitempty"`
+ PricingVersion string `json:"pricing_version,omitempty"`
}
type PricingVendor struct {
@@ -331,11 +332,16 @@ func updatePricing() {
audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model)
pricing.AudioCompletionRatio = &audioCompletionRatio
}
- if billingMode := billing_setting.GetBillingMode(model); billingMode == "tiered_expr" {
+ if billingMode := billing_setting.GetBillingMode(model); billingMode == billing_setting.BillingModeTieredExpr {
if expr, ok := billing_setting.GetBillingExpr(model); ok && strings.TrimSpace(expr) != "" {
pricing.BillingMode = billingMode
pricing.BillingExpr = expr
}
+ } else if billingMode == billing_setting.BillingModeVideoSeconds {
+ if cfg, ok := billing_setting.GetVideoPriceConfig(model); ok && len(cfg.Prices) > 0 {
+ pricing.BillingMode = billingMode
+ pricing.VideoPrice = &cfg
+ }
}
pricingMap = append(pricingMap, pricing)
}
diff --git a/model/user.go b/model/user.go
index 5079acaf2e22..8ee123d650d4 100644
--- a/model/user.go
+++ b/model/user.go
@@ -53,6 +53,7 @@ type User struct {
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
+ LastIp string `json:"last_ip" gorm:"-"`
}
func (user *User) ToBaseUser() *UserBase {
@@ -216,6 +217,10 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err
tx.Rollback()
return nil, 0, err
}
+ if err = fillUsersLastIp(users); err != nil {
+ tx.Rollback()
+ return nil, 0, err
+ }
// Commit transaction
if err = tx.Commit().Error; err != nil {
@@ -283,6 +288,10 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
tx.Rollback()
return nil, 0, err
}
+ if err = fillUsersLastIp(users); err != nil {
+ tx.Rollback()
+ return nil, 0, err
+ }
// 提交事务
if err = tx.Commit().Error; err != nil {
@@ -292,6 +301,64 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
return users, total, nil
}
+func fillUsersLastIp(users []*User) error {
+ if len(users) == 0 {
+ return nil
+ }
+ userIds := make([]int, 0, len(users))
+ userById := make(map[int]*User, len(users))
+ for _, user := range users {
+ if user == nil || user.Id == 0 {
+ continue
+ }
+ userIds = append(userIds, user.Id)
+ userById[user.Id] = user
+ }
+ if len(userIds) == 0 {
+ return nil
+ }
+
+ type latestUserLogId struct {
+ UserId int
+ MaxId int
+ }
+ var latestLogIds []latestUserLogId
+ if err := LOG_DB.Model(&Log{}).
+ Select("user_id, MAX(id) AS max_id").
+ Where("user_id IN ? AND ip <> ?", userIds, "").
+ Group("user_id").
+ Scan(&latestLogIds).Error; err != nil {
+ return err
+ }
+ if len(latestLogIds) == 0 {
+ return nil
+ }
+
+ logIds := make([]int, 0, len(latestLogIds))
+ for _, latestLogId := range latestLogIds {
+ if latestLogId.MaxId > 0 {
+ logIds = append(logIds, latestLogId.MaxId)
+ }
+ }
+ if len(logIds) == 0 {
+ return nil
+ }
+
+ var logs []Log
+ if err := LOG_DB.Model(&Log{}).
+ Select("user_id, ip").
+ Where("id IN ?", logIds).
+ Find(&logs).Error; err != nil {
+ return err
+ }
+ for _, log := range logs {
+ if user := userById[log.UserId]; user != nil {
+ user.LastIp = log.Ip
+ }
+ }
+ return nil
+}
+
func GetUserById(id int, selectAll bool) (*User, error) {
if id == 0 {
return nil, errors.New("id 为空!")
diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go
index ac7e2156063e..1dbd9cc17508 100644
--- a/relay/channel/api_request.go
+++ b/relay/channel/api_request.go
@@ -1,6 +1,7 @@
package channel
import (
+ "bytes"
"context"
"errors"
"fmt"
@@ -538,12 +539,16 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, req
if err != nil {
return nil, err
}
- req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
+ bodyBytes, err := io.ReadAll(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("read request body failed: %w", err)
+ }
+ req, err := http.NewRequest(c.Request.Method, fullRequestURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("new request failed: %w", err)
}
req.GetBody = func() (io.ReadCloser, error) {
- return io.NopCloser(requestBody), nil
+ return io.NopCloser(bytes.NewReader(bodyBytes)), nil
}
err = a.BuildRequestHeader(c, req, info)
diff --git a/relay/channel/api_request_test.go b/relay/channel/api_request_test.go
index f697f8555692..516f7702d014 100644
--- a/relay/channel/api_request_test.go
+++ b/relay/channel/api_request_test.go
@@ -1,11 +1,17 @@
package channel
import (
+ "io"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -191,3 +197,85 @@ func TestProcessHeaderOverride_PassHeadersTemplateSetsRuntimeHeaders(t *testing.
require.Equal(t, "sess-123", upstreamReq.Header.Get("Session_id"))
require.Empty(t, upstreamReq.Header.Get("X-Codex-Beta-Features"))
}
+
+type replayTaskAdaptor struct {
+ url string
+}
+
+func (a replayTaskAdaptor) Init(_ *relaycommon.RelayInfo) {}
+func (a replayTaskAdaptor) ValidateRequestAndSetAction(_ *gin.Context, _ *relaycommon.RelayInfo) *dto.TaskError {
+ return nil
+}
+func (a replayTaskAdaptor) EstimateBilling(_ *gin.Context, _ *relaycommon.RelayInfo) map[string]float64 {
+ return nil
+}
+func (a replayTaskAdaptor) AdjustBillingOnSubmit(_ *relaycommon.RelayInfo, _ []byte) map[string]float64 {
+ return nil
+}
+func (a replayTaskAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
+ return 0
+}
+func (a replayTaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) {
+ return a.url, nil
+}
+func (a replayTaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
+ req.Header.Set("Content-Type", "application/json")
+ return nil
+}
+func (a replayTaskAdaptor) BuildRequestBody(_ *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
+ return strings.NewReader(`{"prompt":"小猫在城市上空急速飞行"}`), nil
+}
+func (a replayTaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
+ return DoTaskApiRequest(a, c, info, requestBody)
+}
+func (a replayTaskAdaptor) DoResponse(_ *gin.Context, _ *http.Response, _ *relaycommon.RelayInfo) (string, []byte, *dto.TaskError) {
+ return "", nil, nil
+}
+func (a replayTaskAdaptor) GetModelList() []string { return nil }
+func (a replayTaskAdaptor) GetChannelName() string { return "replay-test" }
+func (a replayTaskAdaptor) FetchTask(_, _ string, _ map[string]any, _ string) (*http.Response, error) {
+ return nil, nil
+}
+func (a replayTaskAdaptor) ParseTaskResult(_ []byte) (*relaycommon.TaskInfo, error) {
+ return nil, nil
+}
+
+func TestDoTaskApiRequestReplaysBodyAfterRedirect(t *testing.T) {
+ fetchSetting := system_setting.GetFetchSetting()
+ originalFetchSetting := *fetchSetting
+ fetchSetting.EnableSSRFProtection = false
+ defer func() {
+ *fetchSetting = originalFetchSetting
+ }()
+ service.InitHttpClient()
+
+ var finalBody string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/redirect" {
+ http.Redirect(w, r, "/final", http.StatusTemporaryRedirect)
+ return
+ }
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ finalBody = string(body)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ defer server.Close()
+
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", nil)
+
+ adaptor := replayTaskAdaptor{url: server.URL + "/redirect"}
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
+ body, err := adaptor.BuildRequestBody(c, info)
+ require.NoError(t, err)
+ resp, err := DoTaskApiRequest(adaptor, c, info, body)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.JSONEq(t, `{"prompt":"小猫在城市上空急速飞行"}`, finalBody)
+}
diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go
index e177e56dab14..0210f2467eeb 100644
--- a/relay/channel/claude/relay-claude.go
+++ b/relay/channel/claude/relay-claude.go
@@ -1,6 +1,7 @@
package claude
import (
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -44,6 +45,34 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
}
}
+func getOpenAIFileMimeType(file *dto.MessageFile) string {
+ if file == nil {
+ return ""
+ }
+ if strings.HasPrefix(file.FileData, "data:") {
+ if idx := strings.Index(file.FileData, ","); idx > 0 {
+ header := file.FileData[:idx]
+ if end := strings.Index(header, ";"); end > len("data:") {
+ return strings.TrimSpace(header[len("data:"):end])
+ }
+ }
+ }
+ if dot := strings.LastIndex(file.FileName, "."); dot >= 0 && dot+1 < len(file.FileName) {
+ mimeType := service.GetMimeTypeByExtension(file.FileName[dot+1:])
+ if mimeType != "application/octet-stream" {
+ return mimeType
+ }
+ }
+ return ""
+}
+
+func getOpenAIFileBase64Data(fileData string) string {
+ if idx := strings.Index(fileData, ","); strings.HasPrefix(fileData, "data:") && idx >= 0 {
+ return fileData[idx+1:]
+ }
+ return fileData
+}
+
func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
claudeTools := make([]any, 0, len(textRequest.Tools))
@@ -376,6 +405,51 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
Text: common.GetPointer[string](mediaMessage.Text),
})
}
+ case dto.ContentTypeFile:
+ file := mediaMessage.GetFile()
+ if file == nil || file.FileData == "" {
+ continue
+ }
+ mimeType := getOpenAIFileMimeType(file)
+ if mimeType == "" {
+ continue
+ }
+ base64Data := getOpenAIFileBase64Data(file.FileData)
+ if strings.HasPrefix(mimeType, "text/") {
+ decodedData, err := base64.StdEncoding.DecodeString(base64Data)
+ if err != nil {
+ return nil, fmt.Errorf("decode text file data failed: %s", err.Error())
+ }
+ if len(decodedData) > 0 {
+ claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
+ Type: "text",
+ Text: common.GetPointer[string](string(decodedData)),
+ })
+ }
+ continue
+ }
+ if mimeType != "application/pdf" && !strings.HasPrefix(mimeType, "image/") {
+ continue
+ }
+
+ source := types.NewFileSourceFromData(file.FileData, mimeType)
+ base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting file for Claude")
+ if err != nil {
+ return nil, fmt.Errorf("get file data failed: %s", err.Error())
+ }
+ claudeMediaMessage := dto.ClaudeMediaMessage{
+ Source: &dto.ClaudeMessageSource{
+ Type: "base64",
+ MediaType: mimeType,
+ Data: base64Data,
+ },
+ }
+ if strings.HasPrefix(mimeType, "application/pdf") {
+ claudeMediaMessage.Type = "document"
+ } else {
+ claudeMediaMessage.Type = "image"
+ }
+ claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
default:
source := mediaMessage.ToFileSource()
if source == nil {
diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go
index a6dabb5f1086..617760738a6a 100644
--- a/relay/channel/task/doubao/adaptor.go
+++ b/relay/channel/task/doubao/adaptor.go
@@ -273,36 +273,74 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
Content: []ContentItem{},
}
+ metadata := req.Metadata
+ if err := taskcommon.UnmarshalMetadata(metadata, &r); err != nil {
+ return nil, errors.Wrap(err, "unmarshal metadata failed")
+ }
+
// Add images if present
if req.HasImage() {
- for _, imgURL := range req.Images {
+ imageInputs := req.ImageInputs
+ if len(imageInputs) == 0 {
+ imageInputs = make([]relaycommon.TaskImageInput, 0, len(req.Images))
+ for _, imgURL := range req.Images {
+ imageInputs = append(imageInputs, relaycommon.TaskImageInput{URL: imgURL})
+ }
+ }
+ for _, imageInput := range imageInputs {
+ if imageInput.URL == "" {
+ continue
+ }
r.Content = append(r.Content, ContentItem{
Type: "image_url",
ImageURL: &MediaURL{
- URL: imgURL,
+ URL: imageInput.URL,
},
+ Role: imageInput.Role,
})
}
}
- metadata := req.Metadata
- if err := taskcommon.UnmarshalMetadata(metadata, &r); err != nil {
- return nil, errors.Wrap(err, "unmarshal metadata failed")
+ if req.Width > 0 && req.Height > 0 {
+ r.Resolution = fmt.Sprintf("%dp", minInt(req.Width, req.Height))
+ r.Ratio = fmt.Sprintf("%d:%d", req.Width/gcdInt(req.Width, req.Height), req.Height/gcdInt(req.Width, req.Height))
}
- if sec, _ := strconv.Atoi(req.Seconds); sec > 0 {
+ if req.Duration > 0 {
+ r.Duration = lo.ToPtr(dto.IntValue(req.Duration))
+ } else if sec, _ := strconv.Atoi(req.Seconds); sec > 0 {
r.Duration = lo.ToPtr(dto.IntValue(sec))
}
r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" })
- r.Content = append(r.Content, ContentItem{
- Type: "text",
- Text: req.Prompt,
- })
+ r.Content = append([]ContentItem{{Type: "text", Text: req.Prompt}}, r.Content...)
return &r, nil
}
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func gcdInt(a, b int) int {
+ if a < 0 {
+ a = -a
+ }
+ if b < 0 {
+ b = -b
+ }
+ for b != 0 {
+ a, b = b, a%b
+ }
+ if a == 0 {
+ return 1
+ }
+ return a
+}
+
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
resTask := responseTask{}
if err := common.Unmarshal(respBody, &resTask); err != nil {
diff --git a/relay/channel/task/doubao/adaptor_test.go b/relay/channel/task/doubao/adaptor_test.go
new file mode 100644
index 000000000000..51ae7b08b124
--- /dev/null
+++ b/relay/channel/task/doubao/adaptor_test.go
@@ -0,0 +1,117 @@
+package doubao
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConvertUnifiedRequestToDoubaoPayload(t *testing.T) {
+ adaptor := &TaskAdaptor{}
+
+ payload, err := adaptor.convertToRequestPayload(&relaycommon.TaskSubmitReq{
+ Model: "doubao-seedance-2.0",
+ Prompt: "小猫在城市上空急速飞行",
+ Duration: 5,
+ Width: 1280,
+ Height: 720,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, "doubao-seedance-2.0", payload.Model)
+ require.Equal(t, "720p", payload.Resolution)
+ require.Equal(t, "16:9", payload.Ratio)
+ require.NotNil(t, payload.Duration)
+ require.Equal(t, 5, int(*payload.Duration))
+ require.Len(t, payload.Content, 1)
+ require.Equal(t, "text", payload.Content[0].Type)
+ require.Equal(t, "小猫在城市上空急速飞行", payload.Content[0].Text)
+}
+
+func TestConvertUnifiedRequestSerializesOfficialDoubaoContent(t *testing.T) {
+ adaptor := &TaskAdaptor{}
+
+ payload, err := adaptor.convertToRequestPayload(&relaycommon.TaskSubmitReq{
+ Model: "doubao-seedance-2.0",
+ Prompt: "小猫在城市上空急速飞行",
+ Duration: 5,
+ Width: 1280,
+ Height: 720,
+ })
+ require.NoError(t, err)
+
+ data, err := common.Marshal(payload)
+ require.NoError(t, err)
+
+ var body map[string]any
+ require.NoError(t, common.Unmarshal(data, &body))
+ require.Equal(t, "doubao-seedance-2.0", body["model"])
+ require.Equal(t, "720p", body["resolution"])
+ require.Equal(t, "16:9", body["ratio"])
+ require.EqualValues(t, 5, body["duration"])
+
+ content, ok := body["content"].([]any)
+ require.True(t, ok)
+ require.Len(t, content, 1)
+ textItem, ok := content[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "text", textItem["type"])
+ require.Equal(t, "小猫在城市上空急速飞行", textItem["text"])
+}
+
+func TestConvertUnifiedRequestOverridesMetadataText(t *testing.T) {
+ adaptor := &TaskAdaptor{}
+
+ payload, err := adaptor.convertToRequestPayload(&relaycommon.TaskSubmitReq{
+ Model: "doubao-seedance-2.0",
+ Prompt: "用户侧统一提示词",
+ Metadata: map[string]interface{}{
+ "content": []interface{}{
+ map[string]interface{}{
+ "type": "text",
+ "text": "metadata 中的旧提示词",
+ },
+ map[string]interface{}{
+ "type": "image_url",
+ "image_url": map[string]interface{}{
+ "url": "https://example.com/cat.png",
+ },
+ },
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, payload.Content, 2)
+ require.Equal(t, "text", payload.Content[0].Type)
+ require.Equal(t, "用户侧统一提示词", payload.Content[0].Text)
+ require.Equal(t, "image_url", payload.Content[1].Type)
+ require.Equal(t, "https://example.com/cat.png", payload.Content[1].ImageURL.URL)
+}
+
+func TestConvertUnifiedRequestPreservesImageRoles(t *testing.T) {
+ adaptor := &TaskAdaptor{}
+
+ payload, err := adaptor.convertToRequestPayload(&relaycommon.TaskSubmitReq{
+ Model: "doubao-seedance-2.0",
+ Prompt: "hello",
+ Images: []string{
+ "https://example.com/first.jpeg",
+ "https://example.com/last.jpeg",
+ },
+ ImageInputs: []relaycommon.TaskImageInput{
+ {URL: "https://example.com/first.jpeg", Role: "first_frame"},
+ {URL: "https://example.com/last.jpeg", Role: "last_frame"},
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, payload.Content, 3)
+ require.Equal(t, "text", payload.Content[0].Type)
+ require.Equal(t, "https://example.com/first.jpeg", payload.Content[1].ImageURL.URL)
+ require.Equal(t, "first_frame", payload.Content[1].Role)
+ require.Equal(t, "https://example.com/last.jpeg", payload.Content[2].ImageURL.URL)
+ require.Equal(t, "last_frame", payload.Content[2].Role)
+}
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index 64d4d4eedfaa..70d96ed9d5b8 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -674,16 +674,53 @@ type TaskRelayInfo struct {
}
type TaskSubmitReq struct {
- Prompt string `json:"prompt"`
- Model string `json:"model,omitempty"`
- Mode string `json:"mode,omitempty"`
- Image string `json:"image,omitempty"`
- Images []string `json:"images,omitempty"`
- Size string `json:"size,omitempty"`
- Duration int `json:"duration,omitempty"`
- Seconds string `json:"seconds,omitempty"`
- InputReference string `json:"input_reference,omitempty"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
+ Prompt string `json:"prompt"`
+ Model string `json:"model,omitempty"`
+ Mode string `json:"mode,omitempty"`
+ Image string `json:"image,omitempty"`
+ Images []string `json:"images,omitempty"`
+ ImageInputs []TaskImageInput `json:"-"`
+ Size string `json:"size,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Duration int `json:"duration,omitempty"`
+ Seconds string `json:"seconds,omitempty"`
+ FPS int `json:"fps,omitempty"`
+ FrameRate int `json:"frame_rate,omitempty"`
+ FramesPerSecond int `json:"framespersecond,omitempty"`
+ FramesPerSecondCamel int `json:"framesPerSecond,omitempty"`
+ InputReference string `json:"input_reference,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
+}
+
+type TaskImageInput struct {
+ URL string `json:"url,omitempty"`
+ Role string `json:"role,omitempty"`
+}
+
+func (i *TaskImageInput) UnmarshalJSON(data []byte) error {
+ var url string
+ if err := common.Unmarshal(data, &url); err == nil {
+ i.URL = url
+ return nil
+ }
+
+ var obj struct {
+ URL string `json:"url,omitempty"`
+ Role string `json:"role,omitempty"`
+ ImageURL *struct {
+ URL string `json:"url,omitempty"`
+ } `json:"image_url,omitempty"`
+ }
+ if err := common.Unmarshal(data, &obj); err != nil {
+ return err
+ }
+ i.URL = obj.URL
+ i.Role = obj.Role
+ if i.URL == "" && obj.ImageURL != nil {
+ i.URL = obj.ImageURL.URL
+ }
+ return nil
}
func (t *TaskSubmitReq) GetPrompt() string {
@@ -695,19 +732,44 @@ func (t *TaskSubmitReq) HasImage() bool {
}
func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
- type Alias TaskSubmitReq
- aux := &struct {
- Metadata json.RawMessage `json:"metadata,omitempty"`
- Duration json.RawMessage `json:"duration,omitempty"`
- *Alias
- }{
- Alias: (*Alias)(t),
+ var aux struct {
+ Prompt string `json:"prompt"`
+ Model string `json:"model,omitempty"`
+ Mode string `json:"mode,omitempty"`
+ Image string `json:"image,omitempty"`
+ Images json.RawMessage `json:"images,omitempty"`
+ Size string `json:"size,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Duration json.RawMessage `json:"duration,omitempty"`
+ Seconds string `json:"seconds,omitempty"`
+ FPS int `json:"fps,omitempty"`
+ FrameRate int `json:"frame_rate,omitempty"`
+ FramesPerSecond int `json:"framespersecond,omitempty"`
+ FramesPerSecondCamel int `json:"framesPerSecond,omitempty"`
+ InputReference string `json:"input_reference,omitempty"`
+ Metadata json.RawMessage `json:"metadata,omitempty"`
+ Extra map[string]interface{} `json:"-"`
}
if err := common.Unmarshal(data, &aux); err != nil {
return err
}
+ t.Prompt = aux.Prompt
+ t.Model = aux.Model
+ t.Mode = aux.Mode
+ t.Image = aux.Image
+ t.Size = aux.Size
+ t.Width = aux.Width
+ t.Height = aux.Height
+ t.Seconds = aux.Seconds
+ t.FPS = aux.FPS
+ t.FrameRate = aux.FrameRate
+ t.FramesPerSecond = aux.FramesPerSecond
+ t.FramesPerSecondCamel = aux.FramesPerSecondCamel
+ t.InputReference = aux.InputReference
+
if len(aux.Duration) > 0 {
var durationInt int
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
@@ -722,6 +784,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
}
}
+ if len(aux.Images) > 0 {
+ imageInputs, err := parseTaskImageInputs(aux.Images)
+ if err != nil {
+ return err
+ }
+ t.ImageInputs = imageInputs
+ t.Images = make([]string, 0, len(imageInputs))
+ for _, imageInput := range imageInputs {
+ if imageInput.URL != "" {
+ t.Images = append(t.Images, imageInput.URL)
+ }
+ }
+ }
+
if len(aux.Metadata) > 0 {
var metadataStr string
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
@@ -740,6 +816,19 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
return nil
}
+
+func parseTaskImageInputs(data []byte) ([]TaskImageInput, error) {
+ var single TaskImageInput
+ if err := common.Unmarshal(data, &single); err == nil && single.URL != "" {
+ return []TaskImageInput{single}, nil
+ }
+
+ var images []TaskImageInput
+ if err := common.Unmarshal(data, &images); err != nil {
+ return nil, err
+ }
+ return images, nil
+}
func (t *TaskSubmitReq) UnmarshalMetadata(v any) error {
metadata := t.Metadata
if metadata != nil {
diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go
index 18df77a645d6..d50a939b44e2 100644
--- a/relay/common/relay_utils.go
+++ b/relay/common/relay_utils.go
@@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"strings"
+ "unicode/utf8"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
@@ -12,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/samber/lo"
+ "golang.org/x/text/encoding/simplifiedchinese"
)
type HasPrompt interface {
@@ -102,6 +104,10 @@ func validateMultipartTaskRequest(c *gin.Context, info *RelayInfo, action string
if images := formData["images"]; len(images) > 0 {
req.Images = images
+ req.ImageInputs = make([]TaskImageInput, 0, len(images))
+ for _, image := range images {
+ req.ImageInputs = append(req.ImageInputs, TaskImageInput{URL: image})
+ }
}
for key, values := range formData {
@@ -139,6 +145,7 @@ func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError {
}
if req.InputReference != "" {
req.Images = []string{req.InputReference}
+ req.ImageInputs = []TaskImageInput{{URL: req.InputReference}}
}
if strings.TrimSpace(req.Model) == "" {
@@ -206,7 +213,12 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
}
}
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
- if err := common.UnmarshalBodyReusable(c, &req); err != nil {
+ if strings.HasPrefix(contentType, "application/json") {
+ err = unmarshalTaskJSONBody(c, &req)
+ } else {
+ err = common.UnmarshalBodyReusable(c, &req)
+ }
+ if err != nil {
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
}
@@ -217,8 +229,30 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" {
// 兼容单图上传
req.Images = []string{req.Image}
+ req.ImageInputs = []TaskImageInput{{URL: req.Image}}
}
storeTaskRequest(c, info, action, req)
return nil
}
+
+func unmarshalTaskJSONBody(c *gin.Context, req *TaskSubmitReq) error {
+ storage, err := common.GetBodyStorage(c)
+ if err != nil {
+ return err
+ }
+ requestBody, err := storage.Bytes()
+ if err != nil {
+ return err
+ }
+ if utf8.Valid(requestBody) {
+ return common.Unmarshal(requestBody, req)
+ }
+ decoded, decodeErr := simplifiedchinese.GB18030.NewDecoder().Bytes(requestBody)
+ if decodeErr == nil {
+ if err := common.Unmarshal(decoded, req); err == nil {
+ return nil
+ }
+ }
+ return common.Unmarshal(requestBody, req)
+}
diff --git a/relay/common/relay_utils_test.go b/relay/common/relay_utils_test.go
new file mode 100644
index 000000000000..4845c02f5d13
--- /dev/null
+++ b/relay/common/relay_utils_test.go
@@ -0,0 +1,66 @@
+package common
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/encoding/simplifiedchinese"
+)
+
+func TestValidateBasicTaskRequestDecodesGB18030JSONPrompt(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := `{"model":"doubao-seedance-2.0","prompt":"小猫在城市上空急速飞行","duration":5,"width":1280,"height":720}`
+ encodedBody, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(body))
+ require.NoError(t, err)
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", bytes.NewReader(encodedBody))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+
+ info := &RelayInfo{TaskRelayInfo: &TaskRelayInfo{}}
+ taskErr := ValidateBasicTaskRequest(ctx, info, constant.TaskActionGenerate)
+ require.Nil(t, taskErr)
+
+ req, err := GetTaskRequest(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "小猫在城市上空急速飞行", req.Prompt)
+ require.Equal(t, "doubao-seedance-2.0", req.Model)
+ require.Equal(t, 5, req.Duration)
+ require.Equal(t, 1280, req.Width)
+ require.Equal(t, 720, req.Height)
+}
+
+func TestValidateBasicTaskRequestAcceptsImageObjects(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := `{
+ "model":"doubao-seedance-2.0",
+ "prompt":"hello",
+ "images":[
+ {"url":"https://example.com/first.jpeg","role":"first_frame"},
+ {"image_url":{"url":"https://example.com/last.jpeg"},"role":"last_frame"}
+ ],
+ "duration":5
+ }`
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", bytes.NewReader([]byte(body)))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+
+ info := &RelayInfo{TaskRelayInfo: &TaskRelayInfo{}}
+ taskErr := ValidateBasicTaskRequest(ctx, info, constant.TaskActionGenerate)
+ require.Nil(t, taskErr)
+
+ req, err := GetTaskRequest(ctx)
+ require.NoError(t, err)
+ require.Equal(t, []string{"https://example.com/first.jpeg", "https://example.com/last.jpeg"}, req.Images)
+ require.Len(t, req.ImageInputs, 2)
+ require.Equal(t, "first_frame", req.ImageInputs[0].Role)
+ require.Equal(t, "last_frame", req.ImageInputs[1].Role)
+}
diff --git a/relay/helper/price.go b/relay/helper/price.go
index 0e68edba206b..4fabb9cacdf5 100644
--- a/relay/helper/price.go
+++ b/relay/helper/price.go
@@ -2,6 +2,7 @@ package helper
import (
"fmt"
+ "strconv"
"strings"
"github.com/QuantumNous/new-api/common"
@@ -167,6 +168,10 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
groupRatioInfo := HandleGroupRatio(c, info)
+ if billing_setting.GetBillingMode(info.OriginModelName) == billing_setting.BillingModeVideoSeconds {
+ return modelPriceHelperVideoSeconds(c, info, groupRatioInfo)
+ }
+
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
usePrice := success
var modelRatio float64
@@ -224,6 +229,206 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
return priceData, nil
}
+type videoSecondsBillingTrace struct {
+ Resolution string
+ Duration float64
+ FPS float64
+ BaseFPS float64
+ FPSMultiplier float64
+ PricePerSecond float64
+ TotalPrice float64
+}
+
+func (t videoSecondsBillingTrace) toPriceDataTrace() *types.VideoSecondsTrace {
+ return &types.VideoSecondsTrace{
+ Resolution: t.Resolution,
+ Duration: t.Duration,
+ FPS: t.FPS,
+ BaseFPS: t.BaseFPS,
+ FPSMultiplier: t.FPSMultiplier,
+ PricePerSecond: t.PricePerSecond,
+ TotalPrice: t.TotalPrice,
+ }
+}
+
+func modelPriceHelperVideoSeconds(c *gin.Context, info *relaycommon.RelayInfo, groupRatioInfo types.GroupRatioInfo) (types.PriceData, error) {
+ cfg, ok := billing_setting.GetVideoPriceConfig(info.OriginModelName)
+ if !ok || len(cfg.Prices) == 0 {
+ return types.PriceData{}, fmt.Errorf("model %s video per-second price not configured", info.OriginModelName)
+ }
+ req, err := relaycommon.GetTaskRequest(c)
+ if err != nil {
+ return types.PriceData{}, err
+ }
+ trace, err := calculateVideoSecondsBilling(req, cfg)
+ if err != nil {
+ return types.PriceData{}, err
+ }
+ quota := billingexpr.QuotaRound(trace.TotalPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
+ priceData := types.PriceData{
+ ModelPrice: trace.TotalPrice,
+ UsePrice: true,
+ Quota: quota,
+ GroupRatioInfo: groupRatioInfo,
+ VideoSecondsTrace: trace.toPriceDataTrace(),
+ }
+ return priceData, nil
+}
+
+func calculateVideoSecondsBilling(req relaycommon.TaskSubmitReq, cfg billing_setting.VideoPriceConfig) (videoSecondsBillingTrace, error) {
+ resolution := resolveVideoResolution(req)
+ if resolution == "" {
+ return videoSecondsBillingTrace{}, fmt.Errorf("video resolution is required for video per-second billing")
+ }
+ pricePerSecond, ok := lookupVideoResolutionPrice(cfg.Prices, resolution)
+ if !ok || pricePerSecond <= 0 {
+ return videoSecondsBillingTrace{}, fmt.Errorf("video resolution %s price not configured", resolution)
+ }
+ duration := resolveVideoDuration(req)
+ if duration <= 0 {
+ return videoSecondsBillingTrace{}, fmt.Errorf("video duration is required for video per-second billing")
+ }
+ baseFPS := cfg.BaseFPS
+ if baseFPS <= 0 {
+ baseFPS = 24
+ }
+ fps := resolveVideoFPS(req)
+ if fps <= 0 {
+ fps = baseFPS
+ }
+ fpsMultiplier := fps / baseFPS
+ totalPrice := pricePerSecond * duration * fpsMultiplier
+ return videoSecondsBillingTrace{
+ Resolution: resolution,
+ Duration: duration,
+ FPS: fps,
+ BaseFPS: baseFPS,
+ FPSMultiplier: fpsMultiplier,
+ PricePerSecond: pricePerSecond,
+ TotalPrice: totalPrice,
+ }, nil
+}
+
+func lookupVideoResolutionPrice(prices map[string]float64, resolution string) (float64, bool) {
+ normalized := normalizeVideoResolution(resolution)
+ for key, price := range prices {
+ if normalizeVideoResolution(key) == normalized {
+ return price, true
+ }
+ }
+ return 0, false
+}
+
+func resolveVideoDuration(req relaycommon.TaskSubmitReq) float64 {
+ if req.Duration > 0 {
+ return float64(req.Duration)
+ }
+ if sec, err := strconv.ParseFloat(strings.TrimSpace(req.Seconds), 64); err == nil && sec > 0 {
+ return sec
+ }
+ return firstPositiveMetadataNumber(req.Metadata, "duration", "seconds", "duration_seconds", "durationSeconds")
+}
+
+func resolveVideoFPS(req relaycommon.TaskSubmitReq) float64 {
+ for _, v := range []int{req.FPS, req.FrameRate, req.FramesPerSecond, req.FramesPerSecondCamel} {
+ if v > 0 {
+ return float64(v)
+ }
+ }
+ return firstPositiveMetadataNumber(req.Metadata, "fps", "frame_rate", "frameRate", "framespersecond", "framesPerSecond")
+}
+
+func resolveVideoResolution(req relaycommon.TaskSubmitReq) string {
+ for _, key := range []string{"resolution", "quality", "size"} {
+ if v, ok := req.Metadata[key].(string); ok && strings.TrimSpace(v) != "" {
+ return normalizeVideoResolution(v)
+ }
+ }
+ if strings.TrimSpace(req.Size) != "" {
+ if res := resolutionFromSize(req.Size); res != "" {
+ return res
+ }
+ }
+ width := req.Width
+ height := req.Height
+ if width <= 0 {
+ width = int(firstPositiveMetadataNumber(req.Metadata, "width"))
+ }
+ if height <= 0 {
+ height = int(firstPositiveMetadataNumber(req.Metadata, "height"))
+ }
+ if width > 0 && height > 0 {
+ shortSide := width
+ if height < shortSide {
+ shortSide = height
+ }
+ return normalizeVideoResolution(fmt.Sprintf("%dp", shortSide))
+ }
+ return ""
+}
+
+func resolutionFromSize(size string) string {
+ parts := strings.FieldsFunc(strings.ToLower(strings.TrimSpace(size)), func(r rune) bool {
+ return r == 'x' || r == '*' || r == '×'
+ })
+ if len(parts) != 2 {
+ return normalizeVideoResolution(size)
+ }
+ width, errW := strconv.Atoi(strings.TrimSpace(parts[0]))
+ height, errH := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if errW != nil || errH != nil || width <= 0 || height <= 0 {
+ return normalizeVideoResolution(size)
+ }
+ shortSide := width
+ if height < shortSide {
+ shortSide = height
+ }
+ return normalizeVideoResolution(fmt.Sprintf("%dp", shortSide))
+}
+
+func normalizeVideoResolution(resolution string) string {
+ resolution = strings.ToLower(strings.TrimSpace(resolution))
+ resolution = strings.ReplaceAll(resolution, " ", "")
+ if strings.HasSuffix(resolution, "p") {
+ return resolution
+ }
+ if v, err := strconv.Atoi(resolution); err == nil && v > 0 {
+ return fmt.Sprintf("%dp", v)
+ }
+ return resolution
+}
+
+func firstPositiveMetadataNumber(metadata map[string]interface{}, keys ...string) float64 {
+ if metadata == nil {
+ return 0
+ }
+ for _, key := range keys {
+ value, ok := metadata[key]
+ if !ok {
+ continue
+ }
+ switch v := value.(type) {
+ case int:
+ if v > 0 {
+ return float64(v)
+ }
+ case int64:
+ if v > 0 {
+ return float64(v)
+ }
+ case float64:
+ if v > 0 {
+ return v
+ }
+ case string:
+ if n, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && n > 0 {
+ return n
+ }
+ }
+ }
+ return 0
+}
+
func HasModelBillingConfig(modelName string) bool {
if _, ok := ratio_setting.GetModelPrice(modelName, false); ok {
return true
diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go
index afa64c4b0eda..7c0f2b8a110b 100644
--- a/relay/helper/price_test.go
+++ b/relay/helper/price_test.go
@@ -60,3 +60,59 @@ func TestModelPriceHelperTieredUsesPreloadedRequestInput(t *testing.T) {
require.Equal(t, billing_setting.BillingModeTieredExpr, info.TieredBillingSnapshot.BillingMode)
require.Equal(t, common.QuotaPerUnit, info.TieredBillingSnapshot.QuotaPerUnit)
}
+
+func TestCalculateVideoSecondsBilling(t *testing.T) {
+ trace, err := calculateVideoSecondsBilling(relaycommon.TaskSubmitReq{
+ Duration: 5,
+ Width: 1280,
+ Height: 720,
+ FPS: 30,
+ }, billing_setting.VideoPriceConfig{
+ BaseFPS: 24,
+ Prices: map[string]float64{
+ "720p": 1,
+ "1080p": 2,
+ },
+ })
+ require.NoError(t, err)
+ require.Equal(t, "720p", trace.Resolution)
+ require.Equal(t, 5.0, trace.Duration)
+ require.Equal(t, 30.0/24.0, trace.FPSMultiplier)
+ require.Equal(t, 6.25, trace.TotalPrice)
+}
+
+func TestModelPriceHelperVideoSecondsDoesNotExposeBillableRatios(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ saved := map[string]string{}
+ require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
+ saved[key] = value
+ return nil
+ }))
+ t.Cleanup(func() {
+ require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
+ })
+
+ require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
+ "billing_setting.billing_mode": `{"video-test-model":"video_seconds"}`,
+ "billing_setting.video_price": `{"video-test-model":{"base_fps":24,"prices":{"720p":1}}}`,
+ }))
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set("task_request", relaycommon.TaskSubmitReq{
+ Model: "video-test-model",
+ Prompt: "astronaut walking on the moon",
+ Duration: 5,
+ Width: 1280,
+ Height: 720,
+ })
+
+ priceData, err := modelPriceHelperVideoSeconds(ctx, &relaycommon.RelayInfo{
+ OriginModelName: "video-test-model",
+ }, types.GroupRatioInfo{GroupRatio: 1})
+ require.NoError(t, err)
+ require.Equal(t, billingexpr.QuotaRound(5*common.QuotaPerUnit), priceData.Quota)
+ require.Equal(t, 5.0, priceData.ModelPrice)
+ require.Empty(t, priceData.OtherRatios)
+}
diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go
index a9bc5e16a720..b70d8f473ce1 100644
--- a/relay/helper/stream_scanner.go
+++ b/relay/helper/stream_scanner.go
@@ -40,8 +40,10 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
return
}
- // 无条件新建 StreamStatus
- info.StreamStatus = relaycommon.NewStreamStatus()
+ // Reuse an existing StreamStatus so callers can preserve pre-scan errors.
+ if info.StreamStatus == nil {
+ info.StreamStatus = relaycommon.NewStreamStatus()
+ }
// 确保响应体总是被关闭
defer func() {
diff --git a/router/api-router.go b/router/api-router.go
index da026ed92f4d..17934b325b06 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -171,6 +171,12 @@ func SetApiRouter(router *gin.Engine) {
subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription)
}
+ billingRoute := apiRouter.Group("/billing")
+ billingRoute.Use(middleware.AdminAuth())
+ {
+ billingRoute.GET("/statistics", controller.GetBillingStatistics)
+ }
+
// Subscription payment callbacks (no auth)
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
@@ -185,6 +191,8 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats)
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
+ optionRoute.PUT("/ai_translation/settings", controller.UpdateAITranslationSettings)
+ optionRoute.POST("/ai_translation/generate", controller.GenerateAITranslations)
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
}
diff --git a/router/relay-router.go b/router/relay-router.go
index 17a13cad7fd6..bd74f6020843 100644
--- a/router/relay-router.go
+++ b/router/relay-router.go
@@ -71,6 +71,7 @@ func SetRelayRouter(router *gin.Engine) {
relayV1Router.Use(middleware.SystemPerformanceCheck())
relayV1Router.Use(middleware.TokenAuth())
relayV1Router.Use(middleware.ModelRequestRateLimit())
+ relayV1Router.Use(middleware.ModelRequestConcurrencyLimit())
{
// WebSocket 路由(统一到 Relay)
wsRouter := relayV1Router.Group("")
@@ -191,6 +192,7 @@ func SetRelayRouter(router *gin.Engine) {
relayGeminiRouter.Use(middleware.SystemPerformanceCheck())
relayGeminiRouter.Use(middleware.TokenAuth())
relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
+ relayGeminiRouter.Use(middleware.ModelRequestConcurrencyLimit())
relayGeminiRouter.Use(middleware.Distribute())
{
// Gemini API 路径格式: /v1beta/models/{model_name}:{action}
diff --git a/service/ai_translation.go b/service/ai_translation.go
new file mode 100644
index 000000000000..edf961ff084c
--- /dev/null
+++ b/service/ai_translation.go
@@ -0,0 +1,563 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/gin-gonic/gin"
+)
+
+const aiTranslationSnapshotOptionKey = "AITranslationSnapshot"
+
+var aiTranslationSnapshotCache sync.Map
+
+type translationRef struct {
+ value string
+ apply func(string)
+}
+
+type AITranslationSource struct {
+ Scope string
+ Payload any
+ Paths []string
+}
+
+type AITranslationSnapshot struct {
+ Version int `json:"version"`
+ UpdatedAt int64 `json:"updated_at"`
+ Languages map[string]map[string]string `json:"languages"`
+ Stats AITranslationSnapshotStats `json:"stats"`
+}
+
+type AITranslationSnapshotStats struct {
+ SourceTextCount int `json:"source_text_count"`
+ LanguageCounts map[string]int `json:"language_counts"`
+}
+
+type aiTranslationConfig struct {
+ Enabled bool
+ BaseURL string
+ APIKey string
+ Model string
+ TimeoutSeconds int
+}
+
+func TranslateAPIResponse(c *gin.Context, scope string, payload any, paths []string) any {
+ cfg := getAITranslationConfig()
+ if !cfg.Enabled {
+ return payload
+ }
+ lang := detectAITranslationLanguage(c)
+ if lang == "" || lang == "zh" {
+ return payload
+ }
+ snapshot := getStoredAITranslationSnapshot()
+ if snapshot == nil {
+ return payload
+ }
+ translations := snapshot.Languages[lang]
+ if len(translations) == 0 {
+ return payload
+ }
+ return ApplyAITranslations(payload, paths, translations)
+}
+
+func ApplyAITranslations(payload any, paths []string, translations map[string]string) any {
+ if len(translations) == 0 {
+ return payload
+ }
+ var root any
+ raw, err := common.Marshal(payload)
+ if err != nil {
+ return payload
+ }
+ if err = common.Unmarshal(raw, &root); err != nil {
+ return payload
+ }
+
+ refs := collectTranslationRefsFromPaths(root, paths)
+ for _, ref := range refs {
+ text := strings.TrimSpace(ref.value)
+ if translated, ok := translations[text]; ok && strings.TrimSpace(translated) != "" {
+ ref.apply(translated)
+ }
+ }
+ return root
+}
+
+func GenerateAITranslationSnapshot(ctx context.Context, sources []AITranslationSource) (*AITranslationSnapshot, error) {
+ totalStart := time.Now()
+ cfg := getAITranslationConfig()
+ if cfg.APIKey == "" || cfg.Model == "" {
+ return nil, fmt.Errorf("translation API key and model are required")
+ }
+
+ collectStart := time.Now()
+ texts := collectUniqueTranslationTexts(sources)
+ common.SysLog(fmt.Sprintf("AI translation texts collected: sources=%d, items=%d, elapsed=%s", len(sources), len(texts), time.Since(collectStart)))
+ if len(texts) == 0 {
+ return nil, fmt.Errorf("no translatable text found")
+ }
+
+ languages := map[string]map[string]string{
+ "zh": make(map[string]string, len(texts)),
+ }
+ for _, text := range texts {
+ languages["zh"][text] = text
+ }
+
+ targetLanguages := []string{"en", "fr", "ja", "ru", "vi"}
+ existingSnapshot := getStoredAITranslationSnapshot()
+ missingTexts := make([]string, 0)
+ reusedCount := 0
+ for _, lang := range targetLanguages {
+ languages[lang] = make(map[string]string, len(texts))
+ }
+ for _, text := range texts {
+ complete := true
+ for _, lang := range targetLanguages {
+ if existingSnapshot != nil {
+ if existingValue := strings.TrimSpace(existingSnapshot.Languages[lang][text]); existingValue != "" {
+ languages[lang][text] = existingValue
+ continue
+ }
+ }
+ complete = false
+ }
+ if complete {
+ reusedCount++
+ } else {
+ missingTexts = append(missingTexts, text)
+ }
+ }
+ common.SysLog(fmt.Sprintf("AI translation reuse checked: total=%d, reused=%d, missing=%d", len(texts), reusedCount, len(missingTexts)))
+
+ if len(missingTexts) > 0 {
+ translateCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second)
+ defer cancel()
+ common.SysLog(fmt.Sprintf("AI translation generate started: languages=%s, items=%d", strings.Join(targetLanguages, ","), len(missingTexts)))
+ start := time.Now()
+ results, err := requestAITranslations(translateCtx, cfg, targetLanguages, missingTexts)
+ if err != nil {
+ return nil, err
+ }
+ common.SysLog(fmt.Sprintf("AI translation model request finished: languages=%s, items=%d, elapsed=%s", strings.Join(targetLanguages, ","), len(missingTexts), time.Since(start)))
+ for _, lang := range targetLanguages {
+ values := results[lang]
+ if len(values) != len(missingTexts) {
+ return nil, fmt.Errorf("translate %s count mismatch: got %d, want %d", lang, len(values), len(missingTexts))
+ }
+ for i, text := range missingTexts {
+ value := strings.TrimSpace(values[i])
+ if value == "" {
+ value = text
+ }
+ languages[lang][text] = value
+ }
+ }
+ common.SysLog(fmt.Sprintf("AI translation response normalized: languages=%s, items=%d, elapsed=%s", strings.Join(targetLanguages, ","), len(missingTexts), time.Since(start)))
+ } else {
+ common.SysLog("AI translation model request skipped: no changed source text")
+ }
+
+ stats := AITranslationSnapshotStats{
+ SourceTextCount: len(texts),
+ LanguageCounts: make(map[string]int, len(languages)),
+ }
+ for lang, translations := range languages {
+ stats.LanguageCounts[lang] = len(translations)
+ }
+ snapshot := &AITranslationSnapshot{
+ Version: 1,
+ UpdatedAt: time.Now().Unix(),
+ Languages: languages,
+ Stats: stats,
+ }
+ saveStart := time.Now()
+ if err := SaveAITranslationSnapshot(snapshot); err != nil {
+ return nil, err
+ }
+ common.SysLog(fmt.Sprintf("AI translation snapshot saved: elapsed=%s, total=%s", time.Since(saveStart), time.Since(totalStart)))
+ return snapshot, nil
+}
+
+func SaveAITranslationSnapshot(snapshot *AITranslationSnapshot) error {
+ raw, err := common.Marshal(snapshot)
+ if err != nil {
+ return err
+ }
+ aiTranslationSnapshotCache.Delete(aiTranslationSnapshotOptionKey)
+ return model.UpdateOption(aiTranslationSnapshotOptionKey, string(raw))
+}
+
+func collectUniqueTranslationTexts(sources []AITranslationSource) []string {
+ texts := make([]string, 0)
+ seen := make(map[string]struct{})
+ for _, source := range sources {
+ var root any
+ raw, err := common.Marshal(source.Payload)
+ if err != nil {
+ continue
+ }
+ if err = common.Unmarshal(raw, &root); err != nil {
+ continue
+ }
+ for _, ref := range collectTranslationRefsFromPaths(root, source.Paths) {
+ text := strings.TrimSpace(ref.value)
+ if text == "" {
+ continue
+ }
+ if _, ok := seen[text]; ok {
+ continue
+ }
+ seen[text] = struct{}{}
+ texts = append(texts, text)
+ }
+ }
+ return texts
+}
+
+func getStoredAITranslationSnapshot() *AITranslationSnapshot {
+ common.OptionMapRWMutex.RLock()
+ raw := common.OptionMap[aiTranslationSnapshotOptionKey]
+ common.OptionMapRWMutex.RUnlock()
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ if cached, ok := aiTranslationSnapshotCache.Load(raw); ok {
+ return cached.(*AITranslationSnapshot)
+ }
+ var snapshot AITranslationSnapshot
+ if err := common.UnmarshalJsonStr(raw, &snapshot); err != nil {
+ common.SysLog("failed to parse AI translation snapshot: " + err.Error())
+ return nil
+ }
+ aiTranslationSnapshotCache.Store(raw, &snapshot)
+ return &snapshot
+}
+
+func getAITranslationConfig() aiTranslationConfig {
+ common.OptionMapRWMutex.RLock()
+ defer common.OptionMapRWMutex.RUnlock()
+ cfg := aiTranslationConfig{
+ Enabled: parseBoolOption(common.OptionMap["AITranslationEnabled"]),
+ BaseURL: strings.TrimRight(common.OptionMap["AITranslationBaseURL"], "/"),
+ APIKey: common.OptionMap["AITranslationAPIKey"],
+ Model: common.OptionMap["AITranslationModel"],
+ TimeoutSeconds: parseIntOption(common.OptionMap["AITranslationTimeoutSeconds"], 30),
+ }
+ if cfg.BaseURL == "" {
+ cfg.BaseURL = "https://api.openai.com/v1"
+ }
+ if cfg.TimeoutSeconds <= 0 {
+ cfg.TimeoutSeconds = 30
+ }
+ return cfg
+}
+
+func parseBoolOption(value string) bool {
+ value = strings.ToLower(strings.TrimSpace(value))
+ return value == "true" || value == "1" || value == "yes" || value == "on"
+}
+
+func parseIntOption(value string, fallback int) int {
+ n, err := strconv.Atoi(strings.TrimSpace(value))
+ if err != nil {
+ return fallback
+ }
+ return n
+}
+
+func detectAITranslationLanguage(c *gin.Context) string {
+ for _, header := range []string{"X-New-Api-Language", "Accept-Language"} {
+ if value := c.GetHeader(header); value != "" {
+ return normalizeAITranslationLanguage(value)
+ }
+ }
+ return ""
+}
+
+func normalizeAITranslationLanguage(lang string) string {
+ lang = strings.ToLower(strings.TrimSpace(strings.Split(lang, ",")[0]))
+ if idx := strings.Index(lang, ";"); idx >= 0 {
+ lang = strings.TrimSpace(lang[:idx])
+ }
+ switch {
+ case strings.HasPrefix(lang, "zh"):
+ return "zh"
+ case strings.HasPrefix(lang, "en"):
+ return "en"
+ case strings.HasPrefix(lang, "fr"):
+ return "fr"
+ case strings.HasPrefix(lang, "ja"):
+ return "ja"
+ case strings.HasPrefix(lang, "ru"):
+ return "ru"
+ case strings.HasPrefix(lang, "vi"):
+ return "vi"
+ default:
+ return "en"
+ }
+}
+
+func collectTranslationRefsFromPaths(root any, paths []string) []translationRef {
+ refs := make([]translationRef, 0)
+ for _, path := range paths {
+ refs = append(refs, collectTranslationRefs(root, strings.Split(path, "."))...)
+ }
+ return refs
+}
+
+func collectTranslationRefs(node any, parts []string) []translationRef {
+ if len(parts) == 0 {
+ return nil
+ }
+ part := parts[0]
+ if len(parts) == 1 {
+ return collectTranslationLeafRefs(node, part)
+ }
+ nextParts := parts[1:]
+ refs := make([]translationRef, 0)
+ switch current := node.(type) {
+ case map[string]any:
+ if part == "*" {
+ for _, value := range current {
+ refs = append(refs, collectTranslationRefs(value, nextParts)...)
+ }
+ return refs
+ }
+ if value, ok := current[part]; ok {
+ return collectTranslationRefs(value, nextParts)
+ }
+ case []any:
+ if part == "*" {
+ for _, value := range current {
+ refs = append(refs, collectTranslationRefs(value, nextParts)...)
+ }
+ }
+ }
+ return refs
+}
+
+func collectTranslationLeafRefs(node any, part string) []translationRef {
+ refs := make([]translationRef, 0)
+ switch current := node.(type) {
+ case map[string]any:
+ switch part {
+ case "@key":
+ for key := range current {
+ key := key
+ refs = append(refs, translationRef{
+ value: key,
+ apply: func(translated string) {
+ if translated == "" || translated == key {
+ return
+ }
+ if _, exists := current[translated]; exists {
+ return
+ }
+ current[translated] = current[key]
+ delete(current, key)
+ },
+ })
+ }
+ case "@value":
+ for key, value := range current {
+ if text, ok := value.(string); ok {
+ key := key
+ refs = append(refs, translationRef{
+ value: text,
+ apply: func(translated string) {
+ if _, exists := current[key]; exists {
+ current[key] = translated
+ }
+ },
+ })
+ }
+ }
+ default:
+ if value, ok := current[part].(string); ok {
+ refs = append(refs, translationRef{
+ value: value,
+ apply: func(translated string) {
+ current[part] = translated
+ },
+ })
+ return refs
+ }
+ if values, ok := current[part].([]any); ok {
+ for index, value := range values {
+ if text, ok := value.(string); ok {
+ index := index
+ refs = append(refs, translationRef{
+ value: text,
+ apply: func(translated string) {
+ values[index] = translated
+ },
+ })
+ }
+ }
+ }
+ }
+ case []any:
+ if part == "*" {
+ for index, value := range current {
+ if text, ok := value.(string); ok {
+ index := index
+ refs = append(refs, translationRef{
+ value: text,
+ apply: func(translated string) {
+ current[index] = translated
+ },
+ })
+ }
+ }
+ }
+ }
+ return refs
+}
+
+func requestAITranslations(ctx context.Context, cfg aiTranslationConfig, langs []string, texts []string) (map[string][]string, error) {
+ userPayload, err := common.Marshal(gin.H{
+ "target_languages": langs,
+ "items": texts,
+ })
+ if err != nil {
+ return nil, err
+ }
+ bodyMap := gin.H{
+ "model": cfg.Model,
+ "temperature": 0,
+ "stream": false,
+ "enable_thinking": false,
+ "reasoning_effort": "low",
+ "thinking": gin.H{"type": "disabled"},
+ "messages": []gin.H{
+ {
+ "role": "system",
+ "content": "You translate UI/business text for a web application. Translate every natural-language item to every target language. Return only compact JSON in this exact schema: {\"translations\":{\"en\":[\"...\"],\"fr\":[\"...\"],\"ja\":[\"...\"],\"ru\":[\"...\"],\"vi\":[\"...\"]}}. Include exactly one array for each requested target language. Keep placeholders, URLs, API paths, model ids, numbers, currency symbols, and code-like tokens unchanged. Preserve the input order and item count in every language array. If an item is already in the target language, return it unchanged.",
+ },
+ {
+ "role": "user",
+ "content": string(userPayload),
+ },
+ },
+ "response_format": gin.H{"type": "json_object"},
+ }
+ body, err := common.Marshal(bodyMap)
+ if err != nil {
+ return nil, err
+ }
+
+ respBody, err := doAITranslationHTTPRequest(ctx, cfg, body)
+ if err != nil {
+ respBody, err = retryAITranslationHTTPRequest(ctx, cfg, bodyMap, err)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ var chatResp struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+ if err = common.Unmarshal(respBody, &chatResp); err != nil {
+ return nil, err
+ }
+ if len(chatResp.Choices) == 0 {
+ return nil, fmt.Errorf("empty translation response")
+ }
+ content := strings.TrimSpace(chatResp.Choices[0].Message.Content)
+ content = strings.TrimPrefix(content, "```json")
+ content = strings.TrimPrefix(content, "```")
+ content = strings.TrimSuffix(content, "```")
+ content = strings.TrimSpace(content)
+ var parsed struct {
+ Translations map[string][]string `json:"translations"`
+ }
+ if err = common.Unmarshal([]byte(content), &parsed); err != nil {
+ return nil, err
+ }
+ if len(parsed.Translations) == 0 {
+ return nil, fmt.Errorf("empty translation map")
+ }
+ for _, lang := range langs {
+ values := parsed.Translations[lang]
+ if len(values) != len(texts) {
+ return nil, fmt.Errorf("translate %s count mismatch: got %d, want %d", lang, len(values), len(texts))
+ }
+ }
+ return parsed.Translations, nil
+}
+
+func retryAITranslationHTTPRequest(ctx context.Context, cfg aiTranslationConfig, bodyMap gin.H, originalErr error) ([]byte, error) {
+ lastErr := originalErr
+ for i := 0; i < 2; i++ {
+ errText := strings.ToLower(lastErr.Error())
+ retryable := false
+ if strings.Contains(errText, "response_format") {
+ delete(bodyMap, "response_format")
+ retryable = true
+ }
+ if strings.Contains(errText, "thinking") || strings.Contains(errText, "reasoning") || strings.Contains(errText, "valid levels") {
+ delete(bodyMap, "thinking")
+ delete(bodyMap, "enable_thinking")
+ delete(bodyMap, "reasoning_effort")
+ retryable = true
+ }
+ if !retryable {
+ return nil, lastErr
+ }
+ retryBody, err := common.Marshal(bodyMap)
+ if err != nil {
+ return nil, lastErr
+ }
+ respBody, err := doAITranslationHTTPRequest(ctx, cfg, retryBody)
+ if err == nil {
+ return respBody, nil
+ }
+ lastErr = err
+ }
+ return nil, lastErr
+}
+
+func doAITranslationHTTPRequest(ctx context.Context, cfg aiTranslationConfig, body []byte) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.BaseURL+"/chat/completions", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
+
+ start := time.Now()
+ common.SysLog(fmt.Sprintf("AI translation HTTP request started: url=%s, bytes=%d, timeout=%ds", cfg.BaseURL+"/chat/completions", len(body), cfg.TimeoutSeconds))
+ client := &http.Client{Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ common.SysLog(fmt.Sprintf("AI translation HTTP request failed: elapsed=%s, error=%v", time.Since(start), err))
+ return nil, err
+ }
+ defer resp.Body.Close()
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ common.SysLog(fmt.Sprintf("AI translation HTTP response read failed: status=%d, elapsed=%s, error=%v", resp.StatusCode, time.Since(start), err))
+ return nil, err
+ }
+ common.SysLog(fmt.Sprintf("AI translation HTTP request finished: status=%d, response_bytes=%d, elapsed=%s", resp.StatusCode, len(respBody), time.Since(start)))
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
+ }
+ return respBody, nil
+}
diff --git a/service/ai_translation_test.go b/service/ai_translation_test.go
new file mode 100644
index 000000000000..e3404e494f6d
--- /dev/null
+++ b/service/ai_translation_test.go
@@ -0,0 +1,58 @@
+package service
+
+import "testing"
+
+func TestCollectTranslationRefs_TranslatesValuesAndMapKeys(t *testing.T) {
+ root := map[string]any{
+ "data": map[string]any{
+ "默认分组": map[string]any{
+ "desc": "用户分组",
+ },
+ },
+ }
+
+ refs := collectTranslationRefs(root, []string{"data", "@key"})
+ refs = append(refs, collectTranslationRefs(root, []string{"data", "*", "desc"})...)
+ if len(refs) != 2 {
+ t.Fatalf("refs len = %d, want 2", len(refs))
+ }
+ for _, ref := range refs {
+ switch ref.value {
+ case "默认分组":
+ ref.apply("Default group")
+ case "用户分组":
+ ref.apply("User group")
+ default:
+ t.Fatalf("unexpected ref value %q", ref.value)
+ }
+ }
+
+ data := root["data"].(map[string]any)
+ if _, ok := data["默认分组"]; ok {
+ t.Fatal("old map key still exists")
+ }
+ group, ok := data["Default group"].(map[string]any)
+ if !ok {
+ t.Fatalf("translated map key missing: %#v", data)
+ }
+ if group["desc"] != "User group" {
+ t.Fatalf("desc = %#v, want User group", group["desc"])
+ }
+}
+
+func TestNormalizeAITranslationLanguage(t *testing.T) {
+ tests := map[string]string{
+ "fr-FR,fr;q=0.9": "fr",
+ "ja-JP": "ja",
+ "ru": "ru",
+ "vi-VN": "vi",
+ "zh-CN": "zh",
+ "en-US": "en",
+ "es-ES": "en",
+ }
+ for input, want := range tests {
+ if got := normalizeAITranslationLanguage(input); got != want {
+ t.Fatalf("normalizeAITranslationLanguage(%q) = %q, want %q", input, got, want)
+ }
+ }
+}
diff --git a/service/channel_affinity_usage_cache_test.go b/service/channel_affinity_usage_cache_test.go
index 64d3d715b547..ed031d372a8e 100644
--- a/service/channel_affinity_usage_cache_test.go
+++ b/service/channel_affinity_usage_cache_test.go
@@ -3,8 +3,8 @@ package service
import (
"fmt"
"net/http/httptest"
+ "strings"
"testing"
- "time"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/types"
@@ -12,6 +12,12 @@ import (
"github.com/stretchr/testify/require"
)
+func channelAffinityUsageCacheTestKey(t *testing.T, prefix string) string {
+ t.Helper()
+ name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
+ return prefix + "_" + name
+}
+
func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) *gin.Context {
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
@@ -26,9 +32,9 @@ func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string)
}
func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) {
- ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
+ ruleName := channelAffinityUsageCacheTestKey(t, "rule")
usingGroup := "default"
- keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
+ keyFP := channelAffinityUsageCacheTestKey(t, "fp")
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
usage := &dto.Usage{
@@ -53,9 +59,9 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T)
}
func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) {
- ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
+ ruleName := channelAffinityUsageCacheTestKey(t, "rule")
usingGroup := "default"
- keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
+ keyFP := channelAffinityUsageCacheTestKey(t, "fp")
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
openAIUsage := &dto.Usage{
@@ -83,9 +89,9 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) {
}
func TestObserveChannelAffinityUsageCacheByRelayFormat_UnsupportedModeKeepsEmpty(t *testing.T) {
- ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
+ ruleName := channelAffinityUsageCacheTestKey(t, "rule")
usingGroup := "default"
- keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
+ keyFP := channelAffinityUsageCacheTestKey(t, "fp")
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
usage := &dto.Usage{
diff --git a/service/channel_select.go b/service/channel_select.go
index a3710ef8cec3..fc6f826923ce 100644
--- a/service/channel_select.go
+++ b/service/channel_select.go
@@ -12,11 +12,12 @@ import (
)
type RetryParam struct {
- Ctx *gin.Context
- TokenGroup string
- ModelName string
- Retry *int
- resetNextTry bool
+ Ctx *gin.Context
+ TokenGroup string
+ ModelName string
+ Retry *int
+ ExcludeChannelIds map[int]bool
+ resetNextTry bool
}
func (p *RetryParam) GetRetry() int {
@@ -115,7 +116,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
}
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
- channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry)
+ channel, _ = model.GetRandomSatisfiedChannelExcluding(autoGroup, param.ModelName, priorityRetry, param.ExcludeChannelIds)
if channel == nil {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
@@ -153,7 +154,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
break
}
} else {
- channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry())
+ channel, err = model.GetRandomSatisfiedChannelExcluding(param.TokenGroup, param.ModelName, param.GetRetry(), param.ExcludeChannelIds)
if err != nil {
return nil, param.TokenGroup, err
}
diff --git a/service/log_info_generate.go b/service/log_info_generate.go
index 54448d59d673..0164c2917075 100644
--- a/service/log_info_generate.go
+++ b/service/log_info_generate.go
@@ -71,6 +71,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
}
AppendChannelAffinityAdminInfo(ctx, adminInfo)
+ appendModerationInfo(ctx, other, adminInfo)
other["admin_info"] = adminInfo
appendRequestPath(ctx, relayInfo, other)
@@ -82,6 +83,18 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
return other
}
+func appendModerationInfo(ctx *gin.Context, other map[string]interface{}, adminInfo map[string]interface{}) {
+ if ctx == nil || other == nil {
+ return
+ }
+ if value, ok := ctx.Get("moderation_result"); ok && value != nil {
+ other["moderation"] = value
+ if adminInfo != nil {
+ adminInfo["moderation"] = value
+ }
+ }
+}
+
func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if relayInfo == nil || other == nil || len(relayInfo.ParamOverrideAudit) == 0 {
return
diff --git a/service/moderation.go b/service/moderation.go
new file mode 100644
index 000000000000..2e327fc2c0a9
--- /dev/null
+++ b/service/moderation.go
@@ -0,0 +1,266 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/setting"
+ "github.com/QuantumNous/new-api/types"
+)
+
+type moderationImageURL struct {
+ URL string `json:"url"`
+}
+
+type moderationInputPart struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL *moderationImageURL `json:"image_url,omitempty"`
+}
+
+type moderationRequest struct {
+ Model string `json:"model"`
+ Input any `json:"input"`
+}
+
+type moderationResponse struct {
+ ID string `json:"id"`
+ Model string `json:"model"`
+ Results []moderationResultEntry `json:"results"`
+}
+
+type moderationErrorResponse struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Code any `json:"code"`
+ Param string `json:"param"`
+ } `json:"error"`
+}
+
+type moderationResultEntry struct {
+ Flagged bool `json:"flagged"`
+ Categories map[string]bool `json:"categories"`
+ CategoryScores map[string]float64 `json:"category_scores"`
+ CategoryAppliedInputTypes map[string][]string `json:"category_applied_input_types"`
+}
+
+type ModerationResult struct {
+ Action string `json:"action"`
+ Flagged bool `json:"flagged"`
+ Model string `json:"model,omitempty"`
+ BlockedCategories []string `json:"blocked_categories,omitempty"`
+ FlaggedCategories []string `json:"flagged_categories,omitempty"`
+ CategoryScores map[string]float64 `json:"category_scores,omitempty"`
+ CategoryAppliedInputTypes map[string][]string `json:"category_applied_input_types,omitempty"`
+ InputTypes []string `json:"input_types,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func NewModerationErrorResult(err error) *ModerationResult {
+ result := &ModerationResult{
+ Action: "error",
+ }
+ if err != nil {
+ result.Error = err.Error()
+ }
+ return result
+}
+
+func ModerationFailureModeClosed() bool {
+ return setting.NormalizeModerationFailureMode(setting.ModerationFailureMode) == "closed"
+}
+
+func ModerateRelayRequest(ctx context.Context, request dto.Request, meta *types.TokenCountMeta) (*ModerationResult, error) {
+ if !setting.ModerationEnabled {
+ return nil, nil
+ }
+ if strings.TrimSpace(setting.ModerationAPIKey) == "" {
+ return nil, fmt.Errorf("moderation api key is not configured")
+ }
+ if meta == nil && request != nil {
+ meta = request.GetTokenCountMeta()
+ }
+ input, inputTypes := buildModerationInput(meta)
+ if input == nil {
+ return nil, nil
+ }
+
+ model := strings.TrimSpace(setting.ModerationModel)
+ if model == "" {
+ model = "omni-moderation-latest"
+ }
+ baseURL := strings.TrimRight(strings.TrimSpace(setting.ModerationBaseURL), "/")
+ if baseURL == "" {
+ baseURL = "https://api.openai.com/v1"
+ }
+ timeout := time.Duration(setting.ModerationTimeoutSeconds) * time.Second
+ if timeout <= 0 {
+ timeout = 10 * time.Second
+ }
+
+ payload, err := common.Marshal(moderationRequest{
+ Model: model,
+ Input: input,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ reqCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, baseURL+"/moderations", bytes.NewReader(payload))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+setting.ModerationAPIKey)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("moderation endpoint returned status %d: %s", resp.StatusCode, readModerationErrorBody(resp.Body))
+ }
+
+ var parsed moderationResponse
+ if err := common.DecodeJson(resp.Body, &parsed); err != nil {
+ return nil, err
+ }
+ result := normalizeModerationResult(parsed, inputTypes)
+ return result, nil
+}
+
+func readModerationErrorBody(body io.Reader) string {
+ if body == nil {
+ return "empty response body"
+ }
+ data, err := io.ReadAll(io.LimitReader(body, 4096))
+ if err != nil {
+ return "failed to read response body: " + err.Error()
+ }
+ text := strings.TrimSpace(string(data))
+ if text == "" {
+ return "empty response body"
+ }
+
+ var parsed moderationErrorResponse
+ if err := common.Unmarshal(data, &parsed); err == nil && parsed.Error.Message != "" {
+ parts := []string{parsed.Error.Message}
+ if parsed.Error.Type != "" {
+ parts = append(parts, "type="+parsed.Error.Type)
+ }
+ if parsed.Error.Param != "" {
+ parts = append(parts, "param="+parsed.Error.Param)
+ }
+ if parsed.Error.Code != nil {
+ parts = append(parts, "code="+common.Interface2String(parsed.Error.Code))
+ }
+ return strings.Join(parts, ", ")
+ }
+ return text
+}
+
+func buildModerationInput(meta *types.TokenCountMeta) (any, []string) {
+ if meta == nil {
+ return nil, nil
+ }
+ parts := make([]moderationInputPart, 0, 1+len(meta.Files))
+ inputTypes := make([]string, 0, 2)
+ if strings.TrimSpace(meta.CombineText) != "" {
+ parts = append(parts, moderationInputPart{
+ Type: "text",
+ Text: meta.CombineText,
+ })
+ inputTypes = appendUnique(inputTypes, "text")
+ }
+ for _, file := range meta.Files {
+ if file == nil || file.FileType != types.FileTypeImage || file.Source == nil {
+ continue
+ }
+ url := moderationImageSourceURL(file.Source)
+ if url == "" {
+ continue
+ }
+ parts = append(parts, moderationInputPart{
+ Type: "image_url",
+ ImageURL: &moderationImageURL{URL: url},
+ })
+ inputTypes = appendUnique(inputTypes, "image")
+ }
+ if len(parts) == 0 {
+ return nil, nil
+ }
+ if len(parts) == 1 && parts[0].Type == "text" {
+ return parts[0].Text, inputTypes
+ }
+ return parts, inputTypes
+}
+
+func moderationImageSourceURL(source types.FileSource) string {
+ raw := strings.TrimSpace(source.GetRawData())
+ if raw == "" {
+ return ""
+ }
+ if source.IsURL() || strings.HasPrefix(raw, "data:image/") {
+ return raw
+ }
+ if base64Source, ok := source.(*types.Base64Source); ok && base64Source.MimeType != "" {
+ return fmt.Sprintf("data:%s;base64,%s", base64Source.MimeType, raw)
+ }
+ return ""
+}
+
+func normalizeModerationResult(response moderationResponse, inputTypes []string) *ModerationResult {
+ result := &ModerationResult{
+ Action: "pass",
+ Model: response.Model,
+ InputTypes: inputTypes,
+ }
+ if len(response.Results) == 0 {
+ return result
+ }
+ entry := response.Results[0]
+ result.Flagged = entry.Flagged
+ result.CategoryScores = entry.CategoryScores
+ result.CategoryAppliedInputTypes = entry.CategoryAppliedInputTypes
+
+ blockSet := make(map[string]struct{}, len(setting.ModerationBlockCategories))
+ for _, category := range setting.ModerationBlockCategories {
+ blockSet[strings.TrimSpace(category)] = struct{}{}
+ }
+ for category, flagged := range entry.Categories {
+ if !flagged {
+ continue
+ }
+ result.FlaggedCategories = append(result.FlaggedCategories, category)
+ if _, ok := blockSet[category]; ok {
+ result.BlockedCategories = append(result.BlockedCategories, category)
+ }
+ }
+ if len(result.BlockedCategories) > 0 {
+ result.Action = "block"
+ } else if result.Flagged {
+ result.Action = "warn"
+ }
+ return result
+}
+
+func appendUnique(items []string, item string) []string {
+ for _, existing := range items {
+ if existing == item {
+ return items
+ }
+ }
+ return append(items, item)
+}
diff --git a/service/rankings.go b/service/rankings.go
index 01a096ddccfb..acaacde1a680 100644
--- a/service/rankings.go
+++ b/service/rankings.go
@@ -2,11 +2,14 @@ package service
import (
"fmt"
+ "hash/fnv"
"math"
"sort"
+ "strconv"
"sync"
"time"
+ "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)
@@ -18,6 +21,9 @@ const (
rankingMoverLimit = 6
rankingOthersLabel = "Others"
rankingUnknownVendor = "Unknown"
+
+ rankingDisplayMultiplierOption = "RankingsDisplayMultiplier"
+ rankingDisplayJitterOption = "RankingsDisplayJitterRatio"
)
type RankingsResponse struct {
@@ -119,6 +125,11 @@ type rankingModelMeta struct {
vendorIcon string
}
+type rankingDisplaySettings struct {
+ multiplier float64
+ jitter float64
+}
+
type vendorAggregate struct {
name string
icon string
@@ -141,20 +152,22 @@ func GetRankingsSnapshot(period string) (*RankingsResponse, error) {
}
now := time.Now()
+ displaySettings := getRankingDisplaySettings()
+ cacheKey := rankingCacheKey(config, displaySettings)
rankingCacheMu.Lock()
- if item, ok := rankingCache[config.id]; ok && now.Before(item.expiresAt) {
+ if item, ok := rankingCache[cacheKey]; ok && now.Before(item.expiresAt) {
rankingCacheMu.Unlock()
return item.data, nil
}
rankingCacheMu.Unlock()
- data, err := buildRankingsSnapshot(config, now)
+ data, err := buildRankingsSnapshot(config, now, displaySettings)
if err != nil {
return nil, err
}
rankingCacheMu.Lock()
- rankingCache[config.id] = rankingCacheItem{
+ rankingCache[cacheKey] = rankingCacheItem{
expiresAt: now.Add(rankingCacheTTL),
data: data,
}
@@ -180,7 +193,31 @@ func rankingConfig(period string) (rankingPeriodConfig, error) {
}
}
-func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time) (*RankingsResponse, error) {
+func getRankingDisplaySettings() rankingDisplaySettings {
+ common.OptionMapRWMutex.RLock()
+ multiplierValue := common.OptionMap[rankingDisplayMultiplierOption]
+ jitterValue := common.OptionMap[rankingDisplayJitterOption]
+ common.OptionMapRWMutex.RUnlock()
+
+ multiplier, err := strconv.ParseFloat(multiplierValue, 64)
+ if err != nil || multiplier < 0 {
+ multiplier = 1
+ }
+ jitter, err := strconv.ParseFloat(jitterValue, 64)
+ if err != nil || jitter < 0 {
+ jitter = 0
+ }
+ return rankingDisplaySettings{
+ multiplier: multiplier,
+ jitter: jitter,
+ }
+}
+
+func rankingCacheKey(config rankingPeriodConfig, settings rankingDisplaySettings) string {
+ return fmt.Sprintf("%s:display:%g:%g", config.id, settings.multiplier, settings.jitter)
+}
+
+func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time, displaySettings rankingDisplaySettings) (*RankingsResponse, error) {
startTime, endTime := rankingTimeRange(config, now)
currentTotals, err := model.GetRankingQuotaTotals(startTime, endTime)
if err != nil {
@@ -200,6 +237,10 @@ func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time) (*Rankings
}
}
+ currentTotals = applyRankingDisplayToTotals(currentTotals, displaySettings, config.id+":current")
+ currentBuckets = applyRankingDisplayToBuckets(currentBuckets, displaySettings, config.id+":bucket")
+ previousTotals = applyRankingDisplayToTotals(previousTotals, displaySettings, config.id+":previous")
+
meta := buildRankingModelMeta()
totalTokens := sumRankingTokens(currentTotals)
previousRankByModel := rankingRankMap(previousTotals)
@@ -513,6 +554,60 @@ func buildRankingMovers(models []RankedModel) ([]RankingMover, []RankingMover) {
return limitRankingMovers(movers, rankingMoverLimit), limitRankingMovers(droppers, rankingMoverLimit)
}
+func applyRankingDisplayToTotals(totals []model.RankingQuotaTotal, settings rankingDisplaySettings, saltPrefix string) []model.RankingQuotaTotal {
+ if !rankingDisplayEnabled(settings) || len(totals) == 0 {
+ return totals
+ }
+ rows := make([]model.RankingQuotaTotal, len(totals))
+ for i, item := range totals {
+ rows[i] = item
+ rows[i].TotalTokens = rankingDisplayValue(item.TotalTokens, settings, fmt.Sprintf("%s:%s:%d", saltPrefix, item.ModelName, item.TotalTokens))
+ }
+ sort.Slice(rows, func(i, j int) bool {
+ if rows[i].TotalTokens == rows[j].TotalTokens {
+ return rows[i].ModelName < rows[j].ModelName
+ }
+ return rows[i].TotalTokens > rows[j].TotalTokens
+ })
+ return rows
+}
+
+func applyRankingDisplayToBuckets(buckets []model.RankingQuotaBucket, settings rankingDisplaySettings, saltPrefix string) []model.RankingQuotaBucket {
+ if !rankingDisplayEnabled(settings) || len(buckets) == 0 {
+ return buckets
+ }
+ rows := make([]model.RankingQuotaBucket, len(buckets))
+ for i, item := range buckets {
+ rows[i] = item
+ rows[i].Tokens = rankingDisplayValue(item.Tokens, settings, fmt.Sprintf("%s:%d:%s:%d", saltPrefix, item.Bucket, item.ModelName, item.Tokens))
+ }
+ return rows
+}
+
+func rankingDisplayEnabled(settings rankingDisplaySettings) bool {
+ return settings.multiplier != 1 || settings.jitter != 0
+}
+
+func rankingDisplayValue(value int64, settings rankingDisplaySettings, salt string) int64 {
+ if value <= 0 {
+ return 0
+ }
+ scaled := float64(value) * settings.multiplier
+ if settings.jitter > 0 {
+ scaled += scaled * settings.jitter * rankingStableRandom01(salt)
+ }
+ if scaled <= 0 {
+ return 0
+ }
+ return int64(math.Round(scaled))
+}
+
+func rankingStableRandom01(salt string) float64 {
+ hasher := fnv.New64a()
+ _, _ = hasher.Write([]byte(salt))
+ return float64(hasher.Sum64()%1_000_000) / 1_000_000
+}
+
func sortedRankingBuckets(bucketSet map[int64]struct{}) []int64 {
buckets := make([]int64, 0, len(bucketSet))
for bucket := range bucketSet {
diff --git a/service/rankings_test.go b/service/rankings_test.go
new file mode 100644
index 000000000000..47864fc23268
--- /dev/null
+++ b/service/rankings_test.go
@@ -0,0 +1,39 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/model"
+)
+
+func TestRankingDisplayValueMultiplier(t *testing.T) {
+ settings := rankingDisplaySettings{multiplier: 12, jitter: 0}
+
+ value := rankingDisplayValue(100, settings, "model-a")
+
+ if value != 1200 {
+ t.Fatalf("expected multiplier to produce 1200, got %d", value)
+ }
+}
+
+func TestApplyRankingDisplayToTotalsSortsByDisplayedValue(t *testing.T) {
+ settings := rankingDisplaySettings{multiplier: 1, jitter: 1}
+ totals := []model.RankingQuotaTotal{
+ {ModelName: "model-a", TotalTokens: 100},
+ {ModelName: "model-b", TotalTokens: 100},
+ }
+
+ rows := applyRankingDisplayToTotals(totals, settings, "test")
+
+ if len(rows) != len(totals) {
+ t.Fatalf("expected %d rows, got %d", len(totals), len(rows))
+ }
+ for _, row := range rows {
+ if row.TotalTokens < 100 || row.TotalTokens > 200 {
+ t.Fatalf("expected jittered value between 100 and 200, got %d", row.TotalTokens)
+ }
+ }
+ if rows[0].TotalTokens < rows[1].TotalTokens {
+ t.Fatalf("expected rows sorted by displayed value descending: %+v", rows)
+ }
+}
diff --git a/service/task_billing.go b/service/task_billing.go
index 6cf7a965c8eb..076c94f430eb 100644
--- a/service/task_billing.go
+++ b/service/task_billing.go
@@ -10,6 +10,7 @@ import (
"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/billing_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
@@ -39,6 +40,19 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
other["is_task"] = true
other["request_path"] = c.Request.URL.Path
other["model_price"] = info.PriceData.ModelPrice
+ if billingMode := billing_setting.GetBillingMode(info.OriginModelName); billingMode == billing_setting.BillingModeVideoSeconds {
+ other["billing_mode"] = billingMode
+ other["video_total_price"] = info.PriceData.ModelPrice
+ if trace := info.PriceData.VideoSecondsTrace; trace != nil {
+ other["video_resolution"] = trace.Resolution
+ other["video_duration"] = trace.Duration
+ other["video_price_per_second"] = trace.PricePerSecond
+ other["video_fps"] = trace.FPS
+ other["video_base_fps"] = trace.BaseFPS
+ other["video_fps_multiplier"] = trace.FPSMultiplier
+ }
+ logContent = fmt.Sprintf("%s,视频按秒计费", logContent)
+ }
if info.PriceData.ModelRatio > 0 {
other["model_ratio"] = info.PriceData.ModelRatio
}
diff --git a/setting/billing_setting/tiered_billing.go b/setting/billing_setting/tiered_billing.go
index 46dc70de257f..46ab0cdfd366 100644
--- a/setting/billing_setting/tiered_billing.go
+++ b/setting/billing_setting/tiered_billing.go
@@ -9,22 +9,31 @@ import (
)
const (
- BillingModeRatio = "ratio"
- BillingModeTieredExpr = "tiered_expr"
- BillingModeField = "billing_mode"
- BillingExprField = "billing_expr"
+ BillingModeRatio = "ratio"
+ BillingModeTieredExpr = "tiered_expr"
+ BillingModeVideoSeconds = "video_seconds"
+ BillingModeField = "billing_mode"
+ BillingExprField = "billing_expr"
+ VideoPriceField = "video_price"
)
// BillingSetting is managed by config.GlobalConfig.Register.
-// DB keys: billing_setting.billing_mode, billing_setting.billing_expr
+// DB keys: billing_setting.billing_mode, billing_setting.billing_expr, billing_setting.video_price
+type VideoPriceConfig struct {
+ BaseFPS float64 `json:"base_fps,omitempty"`
+ Prices map[string]float64 `json:"prices,omitempty"`
+}
+
type BillingSetting struct {
- BillingMode map[string]string `json:"billing_mode"`
- BillingExpr map[string]string `json:"billing_expr"`
+ BillingMode map[string]string `json:"billing_mode"`
+ BillingExpr map[string]string `json:"billing_expr"`
+ VideoPrice map[string]VideoPriceConfig `json:"video_price"`
}
var billingSetting = BillingSetting{
BillingMode: make(map[string]string),
BillingExpr: make(map[string]string),
+ VideoPrice: make(map[string]VideoPriceConfig),
}
func init() {
@@ -47,6 +56,11 @@ func GetBillingExpr(model string) (string, bool) {
return expr, ok
}
+func GetVideoPriceConfig(model string) (VideoPriceConfig, bool) {
+ cfg, ok := billingSetting.VideoPrice[model]
+ return cfg, ok
+}
+
func GetBillingModeCopy() map[string]string {
return lo.Assign(billingSetting.BillingMode)
}
@@ -55,6 +69,10 @@ func GetBillingExprCopy() map[string]string {
return lo.Assign(billingSetting.BillingExpr)
}
+func GetVideoPriceCopy() map[string]VideoPriceConfig {
+ return lo.Assign(billingSetting.VideoPrice)
+}
+
func GetPricingSyncData(base map[string]any) map[string]any {
extra := make(map[string]any, 2)
if modes := GetBillingModeCopy(); len(modes) > 0 {
@@ -63,6 +81,9 @@ func GetPricingSyncData(base map[string]any) map[string]any {
if exprs := GetBillingExprCopy(); len(exprs) > 0 {
extra[BillingExprField] = exprs
}
+ if videoPrices := GetVideoPriceCopy(); len(videoPrices) > 0 {
+ extra[VideoPriceField] = videoPrices
+ }
return lo.Assign(base, extra)
}
diff --git a/setting/rate_limit.go b/setting/rate_limit.go
index 413f3958d759..c8c4c5515315 100644
--- a/setting/rate_limit.go
+++ b/setting/rate_limit.go
@@ -14,6 +14,9 @@ var ModelRequestRateLimitDurationMinutes = 1
var ModelRequestRateLimitCount = 0
var ModelRequestRateLimitSuccessCount = 1000
var ModelRequestRateLimitGroup = map[string][2]int{}
+var ModelRequestConcurrencyLimitEnabled = false
+var ModelRequestConcurrencyLimitCount = 0
+var ModelRequestConcurrencyLimitGroup = map[string]int{}
var ModelRequestRateLimitMutex sync.RWMutex
func ModelRequestRateLimitGroup2JSONString() string {
@@ -28,13 +31,32 @@ func ModelRequestRateLimitGroup2JSONString() string {
}
func UpdateModelRequestRateLimitGroupByJSONString(jsonStr string) error {
- ModelRequestRateLimitMutex.RLock()
- defer ModelRequestRateLimitMutex.RUnlock()
+ ModelRequestRateLimitMutex.Lock()
+ defer ModelRequestRateLimitMutex.Unlock()
ModelRequestRateLimitGroup = make(map[string][2]int)
return json.Unmarshal([]byte(jsonStr), &ModelRequestRateLimitGroup)
}
+func ModelRequestConcurrencyLimitGroup2JSONString() string {
+ ModelRequestRateLimitMutex.RLock()
+ defer ModelRequestRateLimitMutex.RUnlock()
+
+ jsonBytes, err := json.Marshal(ModelRequestConcurrencyLimitGroup)
+ if err != nil {
+ common.SysLog("error marshalling model request concurrency limit group: " + err.Error())
+ }
+ return string(jsonBytes)
+}
+
+func UpdateModelRequestConcurrencyLimitGroupByJSONString(jsonStr string) error {
+ ModelRequestRateLimitMutex.Lock()
+ defer ModelRequestRateLimitMutex.Unlock()
+
+ ModelRequestConcurrencyLimitGroup = make(map[string]int)
+ return json.Unmarshal([]byte(jsonStr), &ModelRequestConcurrencyLimitGroup)
+}
+
func GetGroupRateLimit(group string) (totalCount, successCount int, found bool) {
ModelRequestRateLimitMutex.RLock()
defer ModelRequestRateLimitMutex.RUnlock()
@@ -50,6 +72,18 @@ func GetGroupRateLimit(group string) (totalCount, successCount int, found bool)
return limits[0], limits[1], true
}
+func GetGroupConcurrencyLimit(group string) (limit int, found bool) {
+ ModelRequestRateLimitMutex.RLock()
+ defer ModelRequestRateLimitMutex.RUnlock()
+
+ if ModelRequestConcurrencyLimitGroup == nil {
+ return 0, false
+ }
+
+ limit, found = ModelRequestConcurrencyLimitGroup[group]
+ return limit, found
+}
+
func CheckModelRequestRateLimitGroup(jsonStr string) error {
checkModelRequestRateLimitGroup := make(map[string][2]int)
err := json.Unmarshal([]byte(jsonStr), &checkModelRequestRateLimitGroup)
@@ -67,3 +101,21 @@ func CheckModelRequestRateLimitGroup(jsonStr string) error {
return nil
}
+
+func CheckModelRequestConcurrencyLimitGroup(jsonStr string) error {
+ checkModelRequestConcurrencyLimitGroup := make(map[string]int)
+ err := json.Unmarshal([]byte(jsonStr), &checkModelRequestConcurrencyLimitGroup)
+ if err != nil {
+ return err
+ }
+ for group, limit := range checkModelRequestConcurrencyLimitGroup {
+ if limit < 0 {
+ return fmt.Errorf("group %s has negative concurrency limit value: %d", group, limit)
+ }
+ if limit > math.MaxInt32 {
+ return fmt.Errorf("group %s concurrency limit value %d exceeds 2147483647", group, limit)
+ }
+ }
+
+ return nil
+}
diff --git a/setting/sensitive.go b/setting/sensitive.go
index 86f9be9a6eb3..fb15262c6762 100644
--- a/setting/sensitive.go
+++ b/setting/sensitive.go
@@ -19,6 +19,18 @@ var SensitiveWords = []string{
"test_sensitive",
}
+var ModerationEnabled = false
+var ModerationModel = "omni-moderation-latest"
+var ModerationBaseURL = "https://api.openai.com/v1"
+var ModerationAPIKey = ""
+var ModerationTimeoutSeconds = 10
+var ModerationFailureMode = "open"
+var ModerationBlockCategories = []string{
+ "sexual/minors",
+ "self-harm/instructions",
+ "illicit/violent",
+}
+
func SensitiveWordsToString() string {
return strings.Join(SensitiveWords, "\n")
}
@@ -38,6 +50,39 @@ func ShouldCheckPromptSensitive() bool {
return CheckSensitiveEnabled && CheckSensitiveOnPromptEnabled
}
+func ShouldModeratePrompt() bool {
+ return ModerationEnabled && strings.TrimSpace(ModerationAPIKey) != ""
+}
+
+func ModerationBlockCategoriesToString() string {
+ return strings.Join(ModerationBlockCategories, "\n")
+}
+
+func ModerationBlockCategoriesFromString(s string) {
+ ModerationBlockCategories = splitModerationList(s)
+}
+
+func NormalizeModerationFailureMode(mode string) string {
+ mode = strings.ToLower(strings.TrimSpace(mode))
+ if mode != "closed" {
+ return "open"
+ }
+ return mode
+}
+
+func splitModerationList(s string) []string {
+ items := []string{}
+ for _, raw := range strings.FieldsFunc(s, func(r rune) bool {
+ return r == '\n' || r == ',' || r == ';'
+ }) {
+ item := strings.TrimSpace(raw)
+ if item != "" {
+ items = append(items, item)
+ }
+ }
+ return items
+}
+
//func ShouldCheckCompletionSensitive() bool {
// return CheckSensitiveEnabled && CheckSensitiveOnCompletionEnabled
//}
diff --git a/types/price_data.go b/types/price_data.go
index 93bc6ae8d168..55d8838918e3 100644
--- a/types/price_data.go
+++ b/types/price_data.go
@@ -21,12 +21,23 @@ type PriceData struct {
AudioRatio float64
AudioCompletionRatio float64
OtherRatios map[string]float64
+ VideoSecondsTrace *VideoSecondsTrace
UsePrice bool
Quota int // 按次计费的最终额度(MJ / Task)
QuotaToPreConsume int // 按量计费的预消耗额度
GroupRatioInfo GroupRatioInfo
}
+type VideoSecondsTrace struct {
+ Resolution string
+ Duration float64
+ FPS float64
+ BaseFPS float64
+ FPSMultiplier float64
+ PricePerSecond float64
+ TotalPrice float64
+}
+
func (p *PriceData) AddOtherRatio(key string, ratio float64) {
if p.OtherRatios == nil {
p.OtherRatios = make(map[string]float64)
diff --git a/web/classic/bun.lock b/web/classic/bun.lock
index 2b5b7a77bf22..fdaeec102f4c 100644
--- a/web/classic/bun.lock
+++ b/web/classic/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "react-template",
diff --git a/web/classic/index.html b/web/classic/index.html
index d6bd2433ea08..5d08d35ea9e4 100644
--- a/web/classic/index.html
+++ b/web/classic/index.html
@@ -1,29 +1,23 @@
-
-
-
-
-
-
-
-
-
New API
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Cooper-API
+
+
+
+
+
+
+
+
+
+
+