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 @@
+测试下这是我自己的修改 ![new-api](/web/default/public/logo.png) 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 + + + + + + +
+ + + + \ No newline at end of file diff --git a/web/classic/public/favicon.ico b/web/classic/public/favicon.ico index ab5f17bcdb35..14336f28da84 100644 Binary files a/web/classic/public/favicon.ico and b/web/classic/public/favicon.ico differ diff --git a/web/default/bun.lock b/web/default/bun.lock index f9dc0300f2bb..bf4639e6cceb 100644 --- a/web/default/bun.lock +++ b/web/default/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "newapi-web", diff --git a/web/default/index.html b/web/default/index.html index d3468f191792..4d3e0ea6e68a 100644 --- a/web/default/index.html +++ b/web/default/index.html @@ -1,24 +1,23 @@ - - - - - - New API - - + + + + - - - - + + Cooper-API | New API + + + + + + + + + +
+ - -
- diff --git a/web/default/public/favicon.ico b/web/default/public/favicon.ico index ab5f17bcdb35..14336f28da84 100644 Binary files a/web/default/public/favicon.ico and b/web/default/public/favicon.ico differ diff --git a/web/default/public/logo.png b/web/default/public/logo.png index 851556f62db5..f74a83dd52f6 100644 Binary files a/web/default/public/logo.png and b/web/default/public/logo.png differ diff --git a/web/default/src/components/language-switcher.tsx b/web/default/src/components/language-switcher.tsx index e7fdcf2fc839..8fb24629bbd4 100644 --- a/web/default/src/components/language-switcher.tsx +++ b/web/default/src/components/language-switcher.tsx @@ -16,15 +16,22 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useCallback } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { INTERFACE_LANGUAGE_OPTIONS, normalizeInterfaceLanguage, } from '@/i18n/languages' -import { Languages, Check } from 'lucide-react' +import { Check, Languages, X } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' import { api } from '@/lib/api' +import { refreshLanguageSensitiveQueries } from '@/lib/i18n-query-refresh' +import { + detectRegionalPromptLanguage, + LANGUAGE_REGION_PROMPT_DISMISSED_KEY, + type RegionalPromptLanguage, +} from '@/lib/regional-language' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { @@ -33,51 +40,130 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' + +const regionalPromptMessages: Record = { + zh: '你可以在这里切换语言', + fr: 'Vous pouvez changer de langue ici.', + ru: 'Здесь можно переключить язык.', + ja: 'ここで言語を切り替えられます。', + vi: 'Bạn có thể đổi ngôn ngữ tại đây.', +} export function LanguageSwitcher() { const { i18n, t } = useTranslation() + const queryClient = useQueryClient() const user = useAuthStore((s) => s.auth.user) const currentLanguage = normalizeInterfaceLanguage(i18n.language) + const [promptLanguage, setPromptLanguage] = + useState(null) + const [promptOpen, setPromptOpen] = useState(false) + const [promptDismissed, setPromptDismissed] = useState(false) + + const dismissRegionalPrompt = useCallback(() => { + setPromptDismissed(true) + setPromptOpen(false) + if (typeof window !== 'undefined') { + window.localStorage.setItem(LANGUAGE_REGION_PROMPT_DISMISSED_KEY, 'true') + } + }, []) + + useEffect(() => { + if (typeof window === 'undefined') return + if ( + window.localStorage.getItem(LANGUAGE_REGION_PROMPT_DISMISSED_KEY) === + 'true' + ) { + return + } + + setPromptDismissed(false) + const detectedLanguage = detectRegionalPromptLanguage() + if (!detectedLanguage) return + + setPromptLanguage(detectedLanguage) + setPromptOpen(true) + }, []) const handleChangeLanguage = useCallback( async (code: string) => { - await i18n.changeLanguage(code) + const nextLanguage = normalizeInterfaceLanguage(code) + await i18n.changeLanguage(nextLanguage) + dismissRegionalPrompt() + refreshLanguageSensitiveQueries(queryClient) if (user) { try { - await api.put('/api/user/self', { language: code }) + await api.put('/api/user/self', { language: nextLanguage }) } catch { // Best-effort persistence; don't block the UI on failure } } }, - [i18n, user] + [dismissRegionalPrompt, i18n, queryClient, user] ) return ( - - } - > - - {t('Change language')} - - - {INTERFACE_LANGUAGE_OPTIONS.map((lang) => ( - handleChangeLanguage(lang.code)} + { + if (open && !promptDismissed) { + setPromptOpen(true) + } + }} + > + }> + + } + onClick={dismissRegionalPrompt} + > + + {t('Change language')} + + + {INTERFACE_LANGUAGE_OPTIONS.map((lang) => ( + handleChangeLanguage(lang.code)} + > + {lang.label} + + + ))} + + + + {promptLanguage && ( + + + {regionalPromptMessages[promptLanguage]} + + + )} + ) } diff --git a/web/default/src/components/layout/components/chat-presets-item.tsx b/web/default/src/components/layout/components/chat-presets-item.tsx index f82c78b261a2..c447a6b09a76 100644 --- a/web/default/src/components/layout/components/chat-presets-item.tsx +++ b/web/default/src/components/layout/components/chat-presets-item.tsx @@ -18,41 +18,28 @@ For commercial licensing, please contact support@quantumnous.com */ import { useMemo, useCallback, useRef, useState } from 'react' import { Link, useLocation } from '@tanstack/react-router' -import { ExternalLink, Loader2, ChevronRight } from 'lucide-react' +import { ExternalLink, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { SidebarMenuButton, SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, useSidebar, } from '@/components/ui/sidebar' -import { fetchActiveChatKey } from '@/features/chat/hooks/use-active-chat-key' +import { ChatKeySelectSheet } from '@/features/chat/components/chat-key-select-sheet' +import { + fetchChatKeyOptions, + fetchChatKeySecret, +} from '@/features/chat/hooks/use-active-chat-key' import { useChatPresets } from '@/features/chat/hooks/use-chat-presets' import { chatLinkRequiresApiKey, resolveChatUrl, type ChatPreset, } from '@/features/chat/lib/chat-links' +import type { ApiKey } from '@/features/keys/types' import { normalizeHref } from '../lib/url-utils' -import type { NavChatPresets } from '../types' -/** - * Sub-menu item for a single chat preset - */ function ChatMenuItem({ preset, active, @@ -68,8 +55,9 @@ function ChatMenuItem({ }) { if (preset.type === 'web') { return ( - - + {preset.name} - - + + ) } return ( - - + { if (!loading) void onOpen(preset) }} @@ -105,59 +94,22 @@ function ChatMenuItem({ ) : ( )} - - - ) -} - -/** - * Dropdown menu item for a single chat preset - */ -function DropdownPresetItem({ - preset, - loading, - onOpen, -}: { - preset: ChatPreset - loading: boolean - onOpen: (preset: ChatPreset) => void | Promise -}) { - if (preset.type === 'web') { - return ( - } - > - {preset.name} - - ) - } - - return ( - { - if (!loading) void onOpen(preset) - }} - > - {preset.name} - {loading ? ( - - ) : ( - - )} - + + ) } -/** - * Dynamic chat presets navigation item - */ -export function ChatPresetsItem({ item }: { item: NavChatPresets }) { +export function ChatPresetsItem() { const { t } = useTranslation() const { chatPresets, serverAddress } = useChatPresets() - const { state, isMobile, setOpenMobile } = useSidebar() + const { setOpenMobile } = useSidebar() const href = useLocation({ select: (location) => location.href }) const [loadingPresetId, setLoadingPresetId] = useState(null) + const [selectingPreset, setSelectingPreset] = useState( + null + ) + const [availableKeys, setAvailableKeys] = useState([]) + const [pendingKeyId, setPendingKeyId] = useState(null) const loadingPresetIdRef = useRef(null) const visiblePresets = useMemo( @@ -165,41 +117,11 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { [chatPresets] ) - const handleOpenExternal = useCallback( - async (preset: ChatPreset) => { - if (preset.type === 'web') return - - const needsKey = chatLinkRequiresApiKey(preset.url) - let activeKey: string | undefined - - if (needsKey && loadingPresetIdRef.current) { - toast.info(t('Preparing your chat link, please try again in a moment.')) - return - } - - if (needsKey) { - loadingPresetIdRef.current = preset.id - setLoadingPresetId(preset.id) - try { - activeKey = await fetchActiveChatKey() - } catch (error) { - const message = - error instanceof Error - ? error.message - : t( - 'Unable to prepare chat link. Please ensure you have an enabled API key.' - ) - toast.error(message) - return - } finally { - loadingPresetIdRef.current = null - setLoadingPresetId(null) - } - } - + const openExternalPreset = useCallback( + (preset: ChatPreset, activeKey?: string) => { const url = resolveChatUrl({ template: preset.url, - apiKey: needsKey ? activeKey : undefined, + apiKey: activeKey, serverAddress, }) @@ -216,69 +138,108 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { [serverAddress, setOpenMobile, t] ) + const handleSelectExternalKey = useCallback( + async (apiKey: ApiKey) => { + if (!selectingPreset || pendingKeyId) return + + setPendingKeyId(apiKey.id) + try { + const secret = await fetchChatKeySecret(apiKey) + setSelectingPreset(null) + openExternalPreset(selectingPreset, secret) + } catch (error) { + const message = + error instanceof Error + ? error.message + : t( + 'Unable to prepare chat link. Please ensure you have an enabled API key.' + ) + toast.error(message) + } finally { + setPendingKeyId(null) + } + }, + [openExternalPreset, pendingKeyId, selectingPreset, t] + ) + + const handleOpenExternal = useCallback( + async (preset: ChatPreset) => { + if (preset.type === 'web') return + + const needsKey = chatLinkRequiresApiKey(preset.url) + + if (!needsKey) { + openExternalPreset(preset) + return + } + + if (loadingPresetIdRef.current) { + toast.info(t('Preparing your chat link, please try again in a moment.')) + return + } + + loadingPresetIdRef.current = preset.id + setLoadingPresetId(preset.id) + try { + const enabledKeys = await fetchChatKeyOptions() + + if (enabledKeys.length === 0) { + toast.error(t('No enabled tokens available')) + return + } + + if (enabledKeys.length === 1) { + const activeKey = await fetchChatKeySecret(enabledKeys[0]) + openExternalPreset(preset, activeKey) + return + } + + setAvailableKeys(enabledKeys) + setSelectingPreset(preset) + setOpenMobile(false) + } catch (error) { + const message = + error instanceof Error + ? error.message + : t( + 'Unable to prepare chat link. Please ensure you have an enabled API key.' + ) + toast.error(message) + } finally { + loadingPresetIdRef.current = null + setLoadingPresetId(null) + } + }, + [openExternalPreset, setOpenMobile, t] + ) + const normalizedHref = normalizeHref(href) - // Don't render if no visible presets if (visiblePresets.length === 0) { return null } - // Collapsed state on non-mobile - render dropdown menu - if (state === 'collapsed' && !isMobile) { - return ( - - - } - > - {item.icon && } - {item.title} - - - - {visiblePresets.map((preset) => ( - - ))} - - - - ) - } - - // Expanded state - render collapsible menu return ( - } - > - } - > - {item.icon && } - {item.title} - - - - - {visiblePresets.map((preset) => ( - setOpenMobile(false)} - /> - ))} - - - + <> + {visiblePresets.map((preset) => ( + setOpenMobile(false)} + /> + ))} + { + if (!open) setSelectingPreset(null) + }} + onSelect={handleSelectExternalKey} + /> + ) } diff --git a/web/default/src/components/layout/components/nav-group.tsx b/web/default/src/components/layout/components/nav-group.tsx index c1688acf095e..860c651e67f9 100644 --- a/web/default/src/components/layout/components/nav-group.tsx +++ b/web/default/src/components/layout/components/nav-group.tsx @@ -48,7 +48,6 @@ import { import { checkIsActive } from '../lib/url-utils' import { type NavCollapsible, - type NavChatPresets, type NavLink, type NavGroup as NavGroupProps, } from '../types' @@ -73,7 +72,7 @@ export function NavGroup({ title, items }: NavGroupProps) { // Special handling: dynamic chat presets list if (item.type === 'chat-presets') { - return + return } // If no sub-items, render regular link diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index b3a5813d7f48..b5ebd0b8bc40 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -155,7 +155,7 @@ export function SignUpForm({ password: data.password, email: data.email || undefined, verification_code: verificationCode || undefined, - aff: getAffiliateCode(), + aff_code: getAffiliateCode(), turnstile: turnstileToken, }) diff --git a/web/default/src/features/auth/types.ts b/web/default/src/features/auth/types.ts index b429e20c250b..60aedd97b067 100644 --- a/web/default/src/features/auth/types.ts +++ b/web/default/src/features/auth/types.ts @@ -37,7 +37,7 @@ export interface RegisterPayload { password: string email?: string verification_code?: string - aff?: string + aff_code?: string turnstile?: string } diff --git a/web/default/src/features/billing-statistics/api.ts b/web/default/src/features/billing-statistics/api.ts new file mode 100644 index 000000000000..d4e1a63d1e74 --- /dev/null +++ b/web/default/src/features/billing-statistics/api.ts @@ -0,0 +1,31 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { api } from '@/lib/api' +import type { + ApiResponse, + BillingStatisticsQuery, + BillingStatisticsResult, +} from './types' + +export async function getBillingStatistics( + params: BillingStatisticsQuery +): Promise> { + const res = await api.get('/api/billing/statistics', { params }) + return res.data +} diff --git a/web/default/src/features/billing-statistics/index.tsx b/web/default/src/features/billing-statistics/index.tsx new file mode 100644 index 000000000000..ab061f9500c9 --- /dev/null +++ b/web/default/src/features/billing-statistics/index.tsx @@ -0,0 +1,567 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + BarChart3, + ChevronLeft, + ChevronRight, + CreditCard, + RefreshCw, + Search, + ShieldCheck, + Wallet, + WalletCards, +} from 'lucide-react' +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import dayjs from '@/lib/dayjs' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { SectionPageLayout } from '@/components/layout' +import { getBillingStatistics } from './api' +import type { BillingStatisticsResult, BillingStatsGranularity } from './types' + +type TimePreset = 'last_hour' | 'today' | 'this_week' | 'this_month' +type ChartGranularity = Extract + +const TIME_PRESETS: Array<{ value: TimePreset; label: string }> = [ + { value: 'last_hour', label: 'Last 1 Hour' }, + { value: 'today', label: 'Today' }, + { value: 'this_week', label: 'This Week' }, + { value: 'this_month', label: 'This Month' }, +] + +const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm' +const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] +const CHART_GRANULARITY_OPTIONS: Array<{ + value: ChartGranularity + label: string +}> = [ + { value: 'day', label: 'Daily' }, + { value: 'month', label: 'Monthly' }, + { value: 'year', label: 'Yearly' }, +] + +const billingChartConfig = { + total_amount: { + label: 'Total Amount', + color: 'var(--chart-1)', + }, + consume_amount: { + label: 'Total Usage Cost', + color: 'var(--chart-2)', + }, +} satisfies ChartConfig + +function formatDateTime(value: dayjs.Dayjs) { + return value.format(DATE_TIME_FORMAT) +} + +function formatRenminbiAmount(value: number | null | undefined) { + if (value == null || Number.isNaN(value)) return '-' + const digits = Math.abs(value) >= 1 ? 2 : 4 + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: 'CNY', + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: digits, + }).format(value) +} + +function getPresetRange(preset: TimePreset) { + const now = dayjs() + switch (preset) { + case 'last_hour': + return { start: now.subtract(1, 'hour'), end: now } + case 'this_week': + return { start: now.startOf('week'), end: now } + case 'this_month': + return { start: now.startOf('month'), end: now } + case 'today': + default: + return { start: now.startOf('day'), end: now } + } +} + +function StatTile(props: { + title: string + value: string + description: string + icon: typeof Wallet + tone: string +}) { + const Icon = props.icon + return ( + + +
+ + {props.title} + + +
+
+
+ {props.value} +
+
+ {props.description} +
+
+
+
+ ) +} + +function BillingStatisticsChart(props: { + data: BillingStatisticsResult | null + loading: boolean +}) { + const { t } = useTranslation() + const rows = props.data?.items ?? [] + + return ( + + + {t('Billing Trend')} + + {t('Total amount and total usage cost by selected granularity')} + + + + {rows.length === 0 ? ( +
+ {props.loading ? t('Loading...') : t('No data')} +
+ ) : ( + + + + + formatRenminbiAmount(Number(value))} + width={72} + /> + ( + <> + + {name === 'total_amount' + ? t('Total Amount') + : t('Total Usage Cost')} + + + {formatRenminbiAmount(Number(value))} + + + )} + /> + } + /> + + + + + )} +
+
+ ) +} + +export function BillingStatistics() { + const { t } = useTranslation() + const initialRange = useMemo(() => getPresetRange('today'), []) + const [startDate, setStartDate] = useState(formatDateTime(initialRange.start)) + const [endDate, setEndDate] = useState(formatDateTime(initialRange.end)) + const [activePreset, setActivePreset] = useState('today') + const [username, setUsername] = useState('') + const [chartGranularity, setChartGranularity] = + useState('day') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + + const params = useMemo( + () => ({ + start_timestamp: dayjs(startDate).unix(), + end_timestamp: dayjs(endDate).unix(), + granularity: chartGranularity, + username: username.trim() || undefined, + p: page, + page_size: pageSize, + }), + [chartGranularity, endDate, page, pageSize, startDate, username] + ) + + const applyPreset = useCallback((preset: TimePreset) => { + const range = getPresetRange(preset) + setStartDate(formatDateTime(range.start)) + setEndDate(formatDateTime(range.end)) + setActivePreset(preset) + setPage(1) + }, []) + + const fetchData = useCallback(async () => { + if (params.end_timestamp <= params.start_timestamp) { + toast.error(t('End date must be after start date')) + return + } + setLoading(true) + try { + const res = await getBillingStatistics(params) + if (res.success) { + setData(res.data) + } + } finally { + setLoading(false) + } + }, [params, t]) + + useEffect(() => { + void fetchData() + }, [fetchData]) + + useEffect(() => { + if (data?.page && data.page !== page) { + setPage(data.page) + } + }, [data?.page, page]) + + const summary = data?.summary + const rows = data?.user_items ?? [] + const totalRows = data?.user_items_total ?? 0 + const totalPages = Math.max(1, data?.total_pages ?? 1) + const displayStart = totalRows === 0 ? 0 : (page - 1) * pageSize + 1 + const displayEnd = totalRows === 0 ? 0 : Math.min(page * pageSize, totalRows) + + return ( + + + {t('Billing Statistics')} + + + {t('Track recharge, subscription and usage costs by time and user')} + + + + + +
+ + + + + +
+ + + + {t('Filters')} + + {t('Select a time range to calculate totals')} + + + +
+ { + setStartDate(event.target.value) + setActivePreset(null) + setPage(1) + }} + /> + { + setEndDate(event.target.value) + setActivePreset(null) + setPage(1) + }} + /> +
+ {TIME_PRESETS.map((item) => ( + + ))} +
+ + { + setUsername(event.target.value) + setPage(1) + }} + /> + +
+
+
+ + + + + + {t('Breakdown')} + + {t('Merged by user in the selected range')} + + + + + + + {t('User')} + + {t('Recharge Amount')} + + + {t('Subscription Amount')} + + {t('Usage Cost')} + + + + {rows.length === 0 ? ( + + + {loading ? t('Loading...') : t('No data')} + + + ) : ( + rows.map((row) => ( + + + {row.username || `${t('User')} #${row.user_id}`} + + + {formatRenminbiAmount(row.recharge_amount)} + + + {formatRenminbiAmount(row.subscription_amount)} + + + {formatRenminbiAmount(row.consume_amount)} + + + )) + )} + +
+
+
+ {t('Showing {{start}}-{{end}} of {{total}} users', { + start: displayStart, + end: displayEnd, + total: totalRows, + })} +
+
+ + {t('Rows per page')} + +
+ {t('Page {{current}} of {{total}}', { + current: Math.min(page, totalPages), + total: totalPages, + })} +
+ +
+
+
+
+
+
+ ) +} diff --git a/web/default/src/features/billing-statistics/types.ts b/web/default/src/features/billing-statistics/types.ts new file mode 100644 index 000000000000..fe196019f165 --- /dev/null +++ b/web/default/src/features/billing-statistics/types.ts @@ -0,0 +1,68 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +export type BillingStatsGranularity = 'hour' | 'day' | 'week' | 'month' | 'year' + +export interface BillingStatisticsQuery { + start_timestamp: number + end_timestamp: number + granularity: BillingStatsGranularity + username?: string + p: number + page_size: number +} + +export interface BillingStatisticsSummary { + recharge_amount: number + subscription_amount: number + total_amount: number + redundant_amount: number + consume_quota: number + consume_amount: number +} + +export interface BillingStatisticsRow extends BillingStatisticsSummary { + bucket_start: number + bucket_label: string + user_id: number + username: string +} + +export interface BillingStatisticsUserRow extends BillingStatisticsSummary { + user_id: number + username: string +} + +export interface BillingStatisticsResult { + start_timestamp: number + end_timestamp: number + granularity: BillingStatsGranularity + page: number + page_size: number + total_pages: number + user_items_total: number + summary: BillingStatisticsSummary + items: BillingStatisticsRow[] + user_items?: BillingStatisticsUserRow[] +} + +export interface ApiResponse { + success: boolean + message?: string + data: T +} diff --git a/web/default/src/features/chat/components/chat-key-select-sheet.tsx b/web/default/src/features/chat/components/chat-key-select-sheet.tsx new file mode 100644 index 000000000000..cb9d6fb553e2 --- /dev/null +++ b/web/default/src/features/chat/components/chat-key-select-sheet.tsx @@ -0,0 +1,100 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { formatQuota } from '@/lib/format' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import type { ApiKey } from '@/features/keys/types' + +type ChatKeySelectSheetProps = { + open: boolean + apiKeys: ApiKey[] + pendingKeyId?: number | null + onOpenChange: (open: boolean) => void + onSelect: (apiKey: ApiKey) => void +} + +export function ChatKeySelectSheet({ + open, + apiKeys, + pendingKeyId, + onOpenChange, + onSelect, +}: ChatKeySelectSheetProps) { + const { t } = useTranslation() + + return ( + + + + {t('Select API key')} + +
+ {apiKeys.map((apiKey) => { + const pending = pendingKeyId === apiKey.id + + return ( + + ) + })} +
+
+
+ ) +} diff --git a/web/default/src/features/chat/hooks/use-active-chat-key.ts b/web/default/src/features/chat/hooks/use-active-chat-key.ts index eaec33b644e9..a0325f8b5d7e 100644 --- a/web/default/src/features/chat/hooks/use-active-chat-key.ts +++ b/web/default/src/features/chat/hooks/use-active-chat-key.ts @@ -20,20 +20,20 @@ import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '@/stores/auth-store' import { fetchTokenKey, getApiKeys } from '@/features/keys/api' import { API_KEY_STATUS } from '@/features/keys/constants' +import type { ApiKey } from '@/features/keys/types' -export async function fetchActiveChatKey() { - const result = await getApiKeys({ p: 1, size: 50 }) +export async function fetchChatKeyOptions() { + const result = await getApiKeys({ p: 1, size: 1000 }) if (!result.success) { throw new Error(result.message || 'Failed to load API keys') } const items = result.data?.items ?? [] - const active = items.find((item) => item.status === API_KEY_STATUS.ENABLED) - if (!active) { - throw new Error('No enabled API keys found. Create or enable one first.') - } + return items.filter((item) => item.status === API_KEY_STATUS.ENABLED) +} - const keyResult = await fetchTokenKey(active.id) +export async function fetchChatKeySecret(apiKey: Pick) { + const keyResult = await fetchTokenKey(apiKey.id) if (!keyResult.success || !keyResult.data?.key) { throw new Error(keyResult.message || 'Failed to load API key') } @@ -41,6 +41,28 @@ export async function fetchActiveChatKey() { return `sk-${keyResult.data.key}` } +export async function fetchActiveChatKey() { + const items = await fetchChatKeyOptions() + const active = items[0] + if (!active) { + throw new Error('No enabled API keys found. Create or enable one first.') + } + + return fetchChatKeySecret(active) +} + +export function useChatKeyOptions(enabled: boolean) { + const userId = useAuthStore((state) => state.auth.user?.id) + + return useQuery({ + queryKey: ['chat-key-options', userId], + queryFn: fetchChatKeyOptions, + enabled: enabled && Boolean(userId), + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }) +} + /** * Get the currently active API key for chat links */ diff --git a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx index 6d09b5e3c7c6..293637bb18fc 100644 --- a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx +++ b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { useEffect, useMemo, useRef, useState } from 'react' import { VChart } from '@visactor/react-vchart' -import { AreaChart, BarChart3, WalletCards } from 'lucide-react' +import { AreaChart, BarChart3, Coins, WalletCards } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useThemeRadiusPx } from '@/lib/theme-radius' import type { TimeGranularity } from '@/lib/time' @@ -32,6 +32,7 @@ import { import { processChartData } from '@/features/dashboard/lib' import type { ConsumptionDistributionChartType, + ConsumptionDistributionMetric, QuotaDataItem, } from '@/features/dashboard/types' @@ -54,6 +55,15 @@ const CHART_TYPE_ICONS: Record< area: AreaChart, } +const METRIC_OPTIONS: { + value: ConsumptionDistributionMetric + labelKey: string + icon: typeof WalletCards +}[] = [ + { value: 'quota', labelKey: 'Amount', icon: WalletCards }, + { value: 'tokens', labelKey: 'Tokens', icon: Coins }, +] + export function ConsumptionDistributionChart( props: ConsumptionDistributionChartProps ) { @@ -67,6 +77,8 @@ export function ConsumptionDistributionChart( const [chartType, setChartType] = useState( props.defaultChartType ?? 'bar' ) + const [metric, setMetric] = + useState('quota') const [themeReady, setThemeReady] = useState(false) const themeManagerRef = useRef< (typeof import('@visactor/vchart'))['ThemeManager'] | null @@ -114,9 +126,21 @@ export function ConsumptionDistributionChart( chartRadius, ] ) - const spec = chartType === 'bar' ? chartData.spec_line : chartData.spec_area + const spec = + metric === 'tokens' + ? chartType === 'bar' + ? chartData.spec_token_line + : chartData.spec_token_area + : chartType === 'bar' + ? chartData.spec_line + : chartData.spec_area + const totalDisplay = + metric === 'tokens' + ? chartData.totalTokensDisplay + : chartData.totalQuotaDisplay const specType = typeof spec?.type === 'string' ? spec.type : chartType const chartKey = [ + metric, chartType, specType, props.loading ? 'loading' : 'ready', @@ -132,29 +156,52 @@ export function ConsumptionDistributionChart(
{t('Quota Distribution')}
- {t('Total:')} {chartData.totalQuotaDisplay} + {t('Total:')} {totalDisplay}
-
- {CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => { - const Icon = CHART_TYPE_ICONS[item.value] - return ( - - ) - })} +
+
+ {METRIC_OPTIONS.map((item) => { + const Icon = item.icon + return ( + + ) + })} +
+ +
+ {CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => { + const Icon = CHART_TYPE_ICONS[item.value] + return ( + + ) + })} +
diff --git a/web/default/src/features/dashboard/components/overview/uptime-panel.tsx b/web/default/src/features/dashboard/components/overview/uptime-panel.tsx index 16c3f824e7e6..321200848d58 100644 --- a/web/default/src/features/dashboard/components/overview/uptime-panel.tsx +++ b/web/default/src/features/dashboard/components/overview/uptime-panel.tsx @@ -43,7 +43,7 @@ const StatusDot = memo(function StatusDot(props: { status: number }) { }) export function UptimePanel() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const [groups, setGroups] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) @@ -69,7 +69,7 @@ export function UptimePanel() { return () => { abortController.abort() } - }, []) + }, [i18n.resolvedLanguage]) const handleRefresh = () => { const abortController = new AbortController() diff --git a/web/default/src/features/dashboard/components/users/user-charts.tsx b/web/default/src/features/dashboard/components/users/user-charts.tsx index 9ddbf80515d6..76d50bfdffba 100644 --- a/web/default/src/features/dashboard/components/users/user-charts.tsx +++ b/web/default/src/features/dashboard/components/users/user-charts.tsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useEffect, useMemo, useState, useRef, useCallback } from 'react' import { useQuery } from '@tanstack/react-query' import { VChart } from '@visactor/react-vchart' -import { Users, Loader2 } from 'lucide-react' +import { Users, Loader2, Coins, WalletCards } from 'lucide-react' import { useTranslation } from 'react-i18next' import { getRollingDateRange, type TimeGranularity } from '@/lib/time' import { VCHART_OPTION } from '@/lib/vchart' @@ -37,7 +37,10 @@ import { saveGranularity, processUserChartData, } from '@/features/dashboard/lib' -import type { ProcessedUserChartData } from '@/features/dashboard/types' +import type { + ConsumptionDistributionMetric, + ProcessedUserChartData, +} from '@/features/dashboard/types' let themeManagerPromise: Promise< (typeof import('@visactor/vchart'))['ThemeManager'] @@ -46,20 +49,35 @@ let themeManagerPromise: Promise< const USER_CHARTS: { value: string labelKey: string - specKey: keyof ProcessedUserChartData + specKeys: Record }[] = [ { value: 'rank', labelKey: 'User Consumption Ranking', - specKey: 'spec_user_rank', + specKeys: { + quota: 'spec_user_rank', + tokens: 'spec_user_token_rank', + }, }, { value: 'trend', labelKey: 'User Consumption Trend', - specKey: 'spec_user_trend', + specKeys: { + quota: 'spec_user_trend', + tokens: 'spec_user_token_trend', + }, }, ] +const METRIC_OPTIONS: { + value: ConsumptionDistributionMetric + labelKey: string + icon: typeof WalletCards +}[] = [ + { value: 'quota', labelKey: 'Amount', icon: WalletCards }, + { value: 'tokens', labelKey: 'Tokens', icon: Coins }, +] + const TOP_USER_LIMIT_OPTIONS = [5, 10, 20, 50] export function UserCharts() { @@ -78,6 +96,8 @@ export function UserCharts() { getDefaultDays(timeGranularity) ) const [topUserLimit, setTopUserLimit] = useState(10) + const [metric, setMetric] = + useState('quota') const [timeRange, setTimeRange] = useState(() => { const days = getDefaultDays(timeGranularity) const { start, end } = getRollingDateRange(days) @@ -210,6 +230,27 @@ export function UserCharts() { ))} +
+ {METRIC_OPTIONS.map((item) => { + const Icon = item.icon + return ( + + ) + })} +
+ {isLoading && ( )} @@ -217,7 +258,21 @@ export function UserCharts() {
{USER_CHARTS.map((chart) => { - const spec = chartData[chart.specKey] + const spec = chartData[chart.specKeys[metric]] + const specType = typeof spec?.type === 'string' ? spec.type : chart.value + const chartKey = [ + 'user', + chart.value, + metric, + specType, + isLoading ? 'loading' : 'ready', + userData?.length ?? 0, + topUserLimit, + timeGranularity, + selectedRange, + resolvedTheme, + customization.preset, + ].join('-') return (
Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) + const formatTokens = (value: number) => + Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) const formatQuotaValue = (value: number) => renderQuotaCompat(value, 4) const formatQuotaTotal = (value: number) => renderQuotaCompat(value, 2) @@ -211,6 +213,24 @@ export function processChartData( stack: true, legends: { visible: true, selectMode: 'single' }, }, + spec_token_line: { + type: 'bar', + data: [{ id: 'tokenBarData', values: [] }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'Model', + stack: true, + legends: { visible: true, selectMode: 'single' }, + }, + spec_token_area: { + type: 'area', + data: [{ id: 'tokenAreaData', values: [] }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'Model', + stack: true, + legends: { visible: true, selectMode: 'single' }, + }, spec_model_line: { type: 'area', data: [{ id: 'lineData', values: [] }], @@ -236,6 +256,7 @@ export function processChartData( }, }, totalQuotaDisplay: formatQuotaTotal(0), + totalTokensDisplay: formatTokens(0), totalCountDisplay: formatInt(0), } } @@ -334,6 +355,10 @@ export function processChartData( (sum, x) => sum + (Number(x.quota) || 0), 0 ) + const totalTokens = Array.from(modelTotalsMap.values()).reduce( + (sum, x) => sum + (Number(x.tokens) || 0), + 0 + ) // Pie chart (model call count proportion) const pieValues = Array.from(modelTotalsMap.entries()) @@ -417,6 +442,122 @@ export function processChartData( }) areaValues.sort((a, b) => a.Time.localeCompare(b.Time)) + const tokenLineValues: Array<{ + Time: string + Model: string + Tokens: number + TimeSum: number + }> = [] + + chartTimes.forEach((time) => { + let timeData = sortedModels.map((model) => { + const stats = timeModelMap.get(time)?.get(model) + const tokens = Number(stats?.tokens) || 0 + return { + Time: time, + Model: model, + Tokens: tokens, + TimeSum: 0, + } + }) + + const timeSum = timeData.reduce((sum, item) => sum + item.Tokens, 0) + timeData.sort((a, b) => b.Tokens - a.Tokens) + timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum })) + tokenLineValues.push(...timeData) + }) + tokenLineValues.sort((a, b) => a.Time.localeCompare(b.Time)) + + const rankedTokenModels = Array.from(modelTotalsMap.entries()) + .map(([model, stats]) => ({ + Model: model, + Tokens: Number(stats.tokens) || 0, + })) + .sort((a, b) => b.Tokens - a.Tokens) + const topTokenAreaModels = new Set( + rankedTokenModels.slice(0, MAX_AREA_MODELS).map((m) => m.Model) + ) + + const tokenAreaValues: typeof tokenLineValues = [] + chartTimes.forEach((time) => { + const buckets = new Map() + const modelMap = timeModelMap.get(time) + let timeSum = 0 + sortedModels.forEach((model) => { + const tokens = Number(modelMap?.get(model)?.tokens) || 0 + timeSum += tokens + const key = topTokenAreaModels.has(model) ? model : otherLabel + const prev = buckets.get(key) || { tokens: 0 } + buckets.set(key, { tokens: prev.tokens + tokens }) + }) + for (const [model, vals] of buckets) { + tokenAreaValues.push({ + Time: time, + Model: model, + Tokens: vals.tokens, + TimeSum: timeSum, + }) + } + }) + tokenAreaValues.sort((a, b) => a.Time.localeCompare(b.Time)) + + const makeTokenTooltipDimensionUpdateContent = (options?: { + collapseOverflow?: boolean + }) => { + const collapseOverflow = options?.collapseOverflow ?? true + + return (array: TooltipLineItem[]) => { + const modelItems = array.filter((item) => !isOtherTooltipKey(item.key)) + const otherItems = array.filter((item) => isOtherTooltipKey(item.key)) + modelItems.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)) + array = [...modelItems, ...otherItems] + + let sum = 0 + for (let i = 0; i < array.length; i++) { + const v = Number(array[i].value) || 0 + if ( + array[i].datum && + (array[i].datum as Record)?.TimeSum + ) { + sum = + Number((array[i].datum as Record)?.TimeSum) || sum + } + array[i].value = formatTokens(v) + } + + if (collapseOverflow && array.length > MAX_TOOLTIP_MODELS) { + const visible = modelItems.slice(0, MAX_TOOLTIP_MODELS) + const otherSum = [ + ...modelItems.slice(MAX_TOOLTIP_MODELS), + ...otherItems, + ].reduce((sum, item) => { + const rawValue = item.datum + ? Number((item.datum as Record)?.Tokens) || 0 + : Number(item.value) || 0 + return sum + rawValue + }, 0) + array = [ + ...visible, + { + key: otherLabel, + value: formatTokens(otherSum), + hasShape: true, + shapeType: 'square', + shapeFill: otherTooltipColor, + shapeStroke: otherTooltipColor, + shapeSize: 8, + }, + ] + } + + array.unshift({ + key: tt('Total:'), + value: formatTokens(sum), + }) + return array + } + } + // Line chart: model call trend (top models + "Other" bucket) const MAX_TREND_MODELS = 20 const rankedTrendModels = Array.from(modelTotalsMap.entries()) @@ -605,6 +746,92 @@ export function processChartData( background: { fill: 'transparent' }, animation: true, }, + spec_token_line: { + type: 'bar', + data: [{ id: 'tokenBarData', values: tokenLineValues }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'Model', + stack: true, + legends: { visible: true, selectMode: 'single' }, + color: modelColor, + bar: { + state: { + hover: { stroke: '#000', lineWidth: 1 }, + }, + }, + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + formatTokens(Number(datum?.Tokens) || 0), + }, + ], + }, + dimension: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + Number(datum?.Tokens) || 0, + }, + ], + updateContent: makeTokenTooltipDimensionUpdateContent(), + }, + }, + background: { fill: 'transparent' }, + animation: true, + }, + spec_token_area: { + type: 'area', + data: [{ id: 'tokenAreaData', values: tokenAreaValues }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'Model', + stack: false, + legends: { visible: true, selectMode: 'single' }, + color: modelColor, + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + formatTokens(Number(datum?.Tokens) || 0), + }, + ], + }, + dimension: { + content: [ + { + key: (datum: Record) => datum?.Model, + value: (datum: Record) => + Number(datum?.Tokens) || 0, + }, + ], + updateContent: makeTokenTooltipDimensionUpdateContent({ + collapseOverflow: false, + }), + }, + }, + area: { + style: { + fillOpacity: 0.08, + curveType: 'monotone', + }, + }, + line: { + style: { + lineWidth: 2, + curveType: 'monotone', + }, + }, + point: { visible: false }, + background: { fill: 'transparent' }, + animation: true, + }, spec_model_line: { type: 'area', data: [{ id: 'lineData', values: modelLineValues }], @@ -715,6 +942,7 @@ export function processChartData( animation: true, }, totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw), + totalTokensDisplay: formatTokens(totalTokens), totalCountDisplay: formatInt(totalTimes), } } @@ -752,6 +980,8 @@ export function processUserChartData( : USER_COLOR_FALLBACKS const formatVal = (raw: number) => renderQuotaCompat(raw, 2) + const formatTokens = (value: number) => + Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) const emptyResult: ProcessedUserChartData = { spec_user_rank: { @@ -786,39 +1016,92 @@ export function processUserChartData( point: { visible: false }, background: { fill: 'transparent' }, }, + spec_user_token_rank: { + type: 'bar', + data: [{ id: 'userTokenRankData', values: [] }], + xField: 'Tokens', + yField: 'User', + seriesField: 'User', + direction: 'horizontal', + title: { + visible: true, + text: tt('User Token Consumption Ranking'), + subtext: tt('No data available'), + }, + legends: { visible: false }, + color: { type: 'ordinal', range: userColorRange }, + background: { fill: 'transparent' }, + }, + spec_user_token_trend: { + type: 'area', + data: [{ id: 'userTokenTrendData', values: [] }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'User', + title: { + visible: true, + text: tt('User Token Consumption Trend'), + subtext: tt('No data available'), + }, + legends: { visible: true, selectMode: 'single' }, + color: { type: 'ordinal', range: userColorRange }, + point: { visible: false }, + background: { fill: 'transparent' }, + }, } if (!data || data.length === 0) return emptyResult - const userQuotaTotal = new Map() + const userQuotaTotal = new Map() data.forEach((item) => { const username = item.username || 'unknown' - const prev = userQuotaTotal.get(username) || 0 - userQuotaTotal.set(username, prev + (Number(item.quota) || 0)) + const prev = userQuotaTotal.get(username) || { quota: 0, tokens: 0 } + userQuotaTotal.set(username, { + quota: prev.quota + (Number(item.quota) || 0), + tokens: prev.tokens + (Number(item.token_used) || 0), + }) }) const sorted = Array.from(userQuotaTotal.entries()).sort( - (a, b) => b[1] - a[1] + (a, b) => b[1].quota - a[1].quota + ) + const sortedByTokens = Array.from(userQuotaTotal.entries()).sort( + (a, b) => b[1].tokens - a[1].tokens ) const topUsers = sorted.slice(0, limit).map(([u]) => u) - const topUserSet = new Set(topUsers) - const totalQuota = sorted.slice(0, limit).reduce((s, [, q]) => s + q, 0) + const topTokenUsers = sortedByTokens.slice(0, limit).map(([u]) => u) + const topUserSet = new Set([...topUsers, ...topTokenUsers]) + const totalQuota = sorted + .slice(0, limit) + .reduce((s, [, stats]) => s + stats.quota, 0) + const totalTokens = sortedByTokens + .slice(0, limit) + .reduce((s, [, stats]) => s + stats.tokens, 0) - const rankValues = sorted.slice(0, limit).map(([username, quota]) => ({ + const rankValues = sorted.slice(0, limit).map(([username, stats]) => ({ User: username, - rawQuota: quota, - Usage: Number((quota / quotaPerUnit).toFixed(4)), + rawQuota: stats.quota, + Usage: Number((stats.quota / quotaPerUnit).toFixed(4)), })) - const userColorMap = topUsers.reduce>( - (acc, user, i) => { - acc[user] = userColorRange[i % userColorRange.length] - return acc - }, - {} - ) + const tokenRankValues = sortedByTokens + .slice(0, limit) + .map(([username, stats]) => ({ + User: username, + Tokens: stats.tokens, + })) + + const userColorMap = Array.from(new Set([...topUsers, ...topTokenUsers])).reduce< + Record + >((acc, user, i) => { + acc[user] = userColorRange[i % userColorRange.length] + return acc + }, {}) - const timeUserMap = new Map>() + const timeUserMap = new Map< + string, + Map + >() const allTimePoints = new Set() data.forEach((item) => { @@ -829,7 +1112,11 @@ export function processUserChartData( if (!topUserSet.has(user)) return if (!timeUserMap.has(timeKey)) timeUserMap.set(timeKey, new Map()) const map = timeUserMap.get(timeKey)! - map.set(user, (map.get(user) || 0) + (Number(item.quota) || 0)) + const prev = map.get(user) || { quota: 0, tokens: 0 } + map.set(user, { + quota: prev.quota + (Number(item.quota) || 0), + tokens: prev.tokens + (Number(item.token_used) || 0), + }) }) const sortedTimePoints = Array.from(allTimePoints).sort() @@ -842,7 +1129,7 @@ export function processUserChartData( sortedTimePoints.forEach((time) => { topUsers.forEach((user) => { - const q = timeUserMap.get(time)?.get(user) || 0 + const q = timeUserMap.get(time)?.get(user)?.quota || 0 trendValues.push({ Time: time, User: user, @@ -852,6 +1139,22 @@ export function processUserChartData( }) }) + const tokenTrendValues: Array<{ + Time: string + User: string + Tokens: number + }> = [] + + sortedTimePoints.forEach((time) => { + topTokenUsers.forEach((user) => { + tokenTrendValues.push({ + Time: time, + User: user, + Tokens: timeUserMap.get(time)?.get(user)?.tokens || 0, + }) + }) + }) + return { spec_user_rank: { type: 'bar', @@ -990,5 +1293,127 @@ export function processUserChartData( background: { fill: 'transparent' }, animation: true, }, + spec_user_token_rank: { + type: 'bar', + data: [{ id: 'userTokenRankData', values: tokenRankValues }], + xField: 'Tokens', + yField: 'User', + seriesField: 'User', + direction: 'horizontal', + title: { + visible: true, + text: tt('User Token Consumption Ranking'), + subtext: `${tt('Total:')} ${formatTokens(totalTokens)}`, + }, + legends: { visible: false }, + bar: { + state: { hover: { stroke: '#000', lineWidth: 1 } }, + }, + label: { + visible: true, + position: 'outside', + formatMethod: (value: number) => formatTokens(value), + style: { fontSize: 11 }, + }, + axes: [ + { orient: 'left', type: 'band' }, + { orient: 'bottom', type: 'linear', visible: false }, + ], + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.User, + value: (datum: Record) => + formatTokens(Number(datum?.Tokens) || 0), + }, + ], + }, + }, + color: { specified: userColorMap }, + background: { fill: 'transparent' }, + animation: true, + }, + spec_user_token_trend: { + type: 'area', + data: [{ id: 'userTokenTrendData', values: tokenTrendValues }], + xField: 'Time', + yField: 'Tokens', + seriesField: 'User', + stack: false, + title: { + visible: true, + text: tt('User Token Consumption Trend'), + subtext: `${tt('Total:')} ${formatTokens(totalTokens)}`, + }, + legends: { visible: true, selectMode: 'single' }, + axes: [ + { orient: 'bottom', type: 'band' }, + { + orient: 'left', + type: 'linear', + label: { + formatMethod: (value: number) => formatTokens(value), + }, + }, + ], + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.User, + value: (datum: Record) => + formatTokens(Number(datum?.Tokens) || 0), + }, + ], + }, + dimension: { + content: [ + { + key: (datum: Record) => datum?.User, + value: (datum: Record) => + Number(datum?.Tokens) || 0, + }, + ], + updateContent: ( + array: Array<{ + key: string + value: string | number + }> + ) => { + array.sort( + (a, b) => (Number(b.value) || 0) - (Number(a.value) || 0) + ) + let sum = 0 + for (let i = 0; i < array.length; i++) { + const v = Number(array[i].value) || 0 + sum += v + array[i].value = formatTokens(v) + } + array.unshift({ + key: tt('Total:'), + value: formatTokens(sum), + }) + return array + }, + }, + }, + area: { + style: { + fillOpacity: 0.15, + curveType: 'monotone', + }, + }, + line: { + style: { + lineWidth: 2, + curveType: 'monotone', + }, + }, + point: { visible: false }, + color: { specified: userColorMap }, + background: { fill: 'transparent' }, + animation: true, + }, } } diff --git a/web/default/src/features/dashboard/types.ts b/web/default/src/features/dashboard/types.ts index ad002e3c3045..171d61d160dc 100644 --- a/web/default/src/features/dashboard/types.ts +++ b/web/default/src/features/dashboard/types.ts @@ -62,6 +62,8 @@ export interface DashboardFilters { export type ConsumptionDistributionChartType = 'bar' | 'area' +export type ConsumptionDistributionMetric = 'quota' | 'tokens' + export type ModelAnalyticsChartTab = 'trend' | 'proportion' | 'top' export interface DashboardChartPreferences { @@ -101,15 +103,20 @@ export interface ProcessedChartData { spec_pie: VChartSpec spec_line: VChartSpec spec_area: VChartSpec + spec_token_line: VChartSpec + spec_token_area: VChartSpec spec_model_line: VChartSpec spec_rank_bar: VChartSpec totalQuotaDisplay: string + totalTokensDisplay: string totalCountDisplay: string } export interface ProcessedUserChartData { spec_user_rank: VChartSpec spec_user_trend: VChartSpec + spec_user_token_rank: VChartSpec + spec_user_token_trend: VChartSpec } // ============================================================================ diff --git a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx index 1a93ad0a1c16..63c2760b3bf3 100644 --- a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -16,20 +16,15 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useEffect, useState, useCallback, useMemo } from 'react' +import { useEffect, useState, useCallback } from 'react' import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { ChevronDown, Loader2 } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible' import { Form, FormControl, @@ -64,20 +59,11 @@ import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { JsonEditor } from '@/components/json-editor' import { TagInput } from '@/components/tag-input' -import { - useSystemOptions, - getOptionValue, -} from '@/features/system-settings/hooks/use-system-options' -import { useUpdateOption } from '@/features/system-settings/hooks/use-update-option' -import { normalizeJsonString } from '@/features/system-settings/models/utils' -import type { ModelSettings } from '@/features/system-settings/types' -import { safeJsonParse } from '@/features/system-settings/utils/json-parser' import { createModel, updateModel, getModel, getVendors } from '../../api' import { getNameRuleOptions, ENDPOINT_TEMPLATES } from '../../constants' import { modelsQueryKeys, vendorsQueryKeys, parseModelTags } from '../../lib' import type { Model } from '../../types' -// Extended schema for ratio configuration (internal form state only) const extendedModelFormSchema = z.object({ id: z.number().optional(), model_name: z.string().min(1, 'Model name is required'), @@ -89,20 +75,10 @@ const extendedModelFormSchema = z.object({ name_rule: z.number(), status: z.boolean(), sync_official: z.boolean(), - price: z.string().optional(), - ratio: z.string().optional(), - cacheRatio: z.string().optional(), - completionRatio: z.string().optional(), - imageRatio: z.string().optional(), - audioRatio: z.string().optional(), - audioCompletionRatio: z.string().optional(), }) type ExtendedModelFormValues = z.infer -type PricingMode = 'per-token' | 'per-request' -type PricingSubMode = 'ratio' | 'price' - type ModelMutateDrawerProps = { open: boolean onOpenChange: (open: boolean) => void @@ -118,12 +94,6 @@ export function ModelMutateDrawer({ const queryClient = useQueryClient() const isEditing = Boolean(currentRow?.id) const [isSubmitting, setIsSubmitting] = useState(false) - const [pricingMode, setPricingMode] = useState('per-token') - const [pricingSubMode, setPricingSubMode] = useState('ratio') - const [advancedOpen, setAdvancedOpen] = useState(false) - const [promptPrice, setPromptPrice] = useState('') - const [completionPrice, setCompletionPrice] = useState('') - const [oldModelName, setOldModelName] = useState('') // Fetch vendors for dropdown const { data: vendorsData } = useQuery({ @@ -141,63 +111,6 @@ export function ModelMutateDrawer({ enabled: open && isEditing, }) - // Fetch system options for ratio configuration - const { data: systemOptionsData } = useSystemOptions() - - const updateOption = useUpdateOption() - - // Get model settings from system options - const modelSettings = useMemo(() => { - if (!systemOptionsData?.data) return null - const defaultModelSettings: ModelSettings = { - 'global.pass_through_request_enabled': false, - 'global.thinking_model_blacklist': '[]', - 'global.chat_completions_to_responses_policy': '{}', - 'general_setting.ping_interval_enabled': false, - 'general_setting.ping_interval_seconds': 60, - 'gemini.safety_settings': '', - 'gemini.version_settings': '', - 'gemini.supported_imagine_models': '', - 'gemini.thinking_adapter_enabled': false, - 'gemini.thinking_adapter_budget_tokens_percentage': 0.6, - 'gemini.function_call_thought_signature_enabled': false, - 'gemini.remove_function_response_id_enabled': true, - 'claude.model_headers_settings': '', - 'claude.default_max_tokens': '', - 'claude.thinking_adapter_enabled': true, - 'claude.thinking_adapter_budget_tokens_percentage': 0.8, - ModelPrice: '', - ModelRatio: '', - CacheRatio: '', - CompletionRatio: '', - ImageRatio: '', - AudioRatio: '', - AudioCompletionRatio: '', - ExposeRatioEnabled: false, - 'billing_setting.billing_mode': '{}', - 'billing_setting.billing_expr': '{}', - 'tool_price_setting.prices': '{}', - TopupGroupRatio: '', - GroupRatio: '', - UserUsableGroups: '', - GroupGroupRatio: '', - AutoGroups: '', - DefaultUseAutoGroup: false, - CreateCacheRatio: '', - 'group_ratio_setting.group_special_usable_group': '{}', - 'grok.violation_deduction_enabled': false, - 'grok.violation_deduction_amount': 0, - 'channel_affinity_setting.enabled': false, - 'channel_affinity_setting.switch_on_success': true, - 'channel_affinity_setting.max_entries': 100000, - 'channel_affinity_setting.default_ttl_seconds': 3600, - 'channel_affinity_setting.rules': '[]', - 'model_deployment.ionet.api_key': '', - 'model_deployment.ionet.enabled': false, - } - return getOptionValue(systemOptionsData.data, defaultModelSettings) - }, [systemOptionsData]) - const form = useForm({ resolver: zodResolver(extendedModelFormSchema), defaultValues: { @@ -210,55 +123,15 @@ export function ModelMutateDrawer({ name_rule: 0, status: true, sync_official: true, - price: '', - ratio: '', - cacheRatio: '', - completionRatio: '', - imageRatio: '', - audioRatio: '', - audioCompletionRatio: '', }, }) - const validateNumber = (value: string) => { - if (value === '') return true - return !isNaN(parseFloat(value)) - } - - const handlePromptPriceChange = (value: string) => { - setPromptPrice(value) - if (value && !isNaN(parseFloat(value))) { - const ratio = parseFloat(value) / 2 - form.setValue('ratio', ratio.toString()) - } else { - form.setValue('ratio', '') - } - } - - const handleCompletionPriceChange = (value: string) => { - setCompletionPrice(value) - if ( - value && - !isNaN(parseFloat(value)) && - promptPrice && - !isNaN(parseFloat(promptPrice)) && - parseFloat(promptPrice) > 0 - ) { - const completionRatio = parseFloat(value) / parseFloat(promptPrice) - form.setValue('completionRatio', completionRatio.toString()) - } else { - form.setValue('completionRatio', '') - } - } - - // Load model data for editing and ratio configuration + // Load model data for editing useEffect(() => { if (open && isEditing && modelData?.data) { const model = modelData.data - setOldModelName(model.model_name) - // Base model data reset - const baseModelData = { + form.reset({ id: model.id, model_name: model.model_name, description: model.description || '', @@ -269,100 +142,9 @@ export function ModelMutateDrawer({ name_rule: model.name_rule || 0, status: model.status === 1, sync_official: model.sync_official === 1, - price: '', - ratio: '', - cacheRatio: '', - completionRatio: '', - imageRatio: '', - audioRatio: '', - audioCompletionRatio: '', - } - - // Parse ratio configurations from system settings if available - if (modelSettings) { - const priceMap = safeJsonParse>( - modelSettings.ModelPrice, - { fallback: {}, silent: true } - ) - const ratioMap = safeJsonParse>( - modelSettings.ModelRatio, - { fallback: {}, silent: true } - ) - const cacheMap = safeJsonParse>( - modelSettings.CacheRatio, - { fallback: {}, silent: true } - ) - const completionMap = safeJsonParse>( - modelSettings.CompletionRatio, - { fallback: {}, silent: true } - ) - const imageMap = safeJsonParse>( - modelSettings.ImageRatio, - { fallback: {}, silent: true } - ) - const audioMap = safeJsonParse>( - modelSettings.AudioRatio, - { fallback: {}, silent: true } - ) - const audioCompletionMap = safeJsonParse>( - modelSettings.AudioCompletionRatio, - { fallback: {}, silent: true } - ) - - // Extract ratio config for this model - const modelName = model.model_name - const price = priceMap[modelName] - const ratio = ratioMap[modelName] - const cacheRatio = cacheMap[modelName] - const completionRatio = completionMap[modelName] - const imageRatio = imageMap[modelName] - const audioRatio = audioMap[modelName] - const audioCompletionRatio = audioCompletionMap[modelName] - - // Determine pricing mode - if (price !== undefined && price !== null) { - setPricingMode('per-request') - form.reset({ - ...baseModelData, - price: price.toString(), - }) - } else { - setPricingMode('per-token') - if (ratio !== undefined && ratio !== null) { - const tokenPrice = ratio * 2 - setPromptPrice(tokenPrice.toString()) - if (completionRatio !== undefined && completionRatio !== null) { - const compPrice = tokenPrice * completionRatio - setCompletionPrice(compPrice.toString()) - } - } - form.reset({ - ...baseModelData, - ratio: ratio?.toString() || '', - cacheRatio: cacheRatio?.toString() || '', - completionRatio: completionRatio?.toString() || '', - imageRatio: imageRatio?.toString() || '', - audioRatio: audioRatio?.toString() || '', - audioCompletionRatio: audioCompletionRatio?.toString() || '', - }) - setAdvancedOpen( - !!(cacheRatio || imageRatio || audioRatio || audioCompletionRatio) - ) - } - } else { - // If system settings not loaded yet, just load base model data - setPricingMode('per-token') - form.reset(baseModelData) - setAdvancedOpen(false) - } + }) } else if (open && !isEditing) { // Pre-fill model name if passed from missing models - setOldModelName('') - setPricingMode('per-token') - setPricingSubMode('ratio') - setPromptPrice('') - setCompletionPrice('') - setAdvancedOpen(false) form.reset({ model_name: currentRow?.model_name || '', description: '', @@ -373,16 +155,9 @@ export function ModelMutateDrawer({ name_rule: 0, status: true, sync_official: true, - price: '', - ratio: '', - cacheRatio: '', - completionRatio: '', - imageRatio: '', - audioRatio: '', - audioCompletionRatio: '', }) } - }, [open, isEditing, modelData, currentRow, form, modelSettings]) + }, [open, isEditing, modelData, currentRow, form]) const onSubmit = useCallback( async (values: ExtendedModelFormValues): Promise => { @@ -396,198 +171,11 @@ export function ModelMutateDrawer({ sync_official: values.sync_official ? 1 : 0, } - // Remove ratio fields from model data (they're stored in system settings) - const { - price, - ratio, - cacheRatio, - completionRatio, - imageRatio, - audioRatio, - audioCompletionRatio, - ...modelData - } = submitData - const response = isEditing - ? await updateModel({ ...modelData, id: currentRow!.id }) - : await createModel(modelData) + ? await updateModel({ ...submitData, id: currentRow!.id }) + : await createModel(submitData) if (response.success) { - // Handle ratio configuration updates in system settings - const finalModelName = values.model_name - const hasRatioConfig = - (pricingMode === 'per-request' && - values.price && - values.price !== '') || - (pricingMode === 'per-token' && - (values.ratio || - values.cacheRatio || - values.completionRatio || - values.imageRatio || - values.audioRatio || - values.audioCompletionRatio)) - - // Always process system settings updates if we have modelSettings - // This ensures we can remove stale entries even when clearing all pricing fields - if (modelSettings) { - // Read existing configurations - const priceMap = safeJsonParse>( - modelSettings.ModelPrice, - { fallback: {}, silent: true } - ) - const ratioMap = safeJsonParse>( - modelSettings.ModelRatio, - { fallback: {}, silent: true } - ) - const cacheMap = safeJsonParse>( - modelSettings.CacheRatio, - { fallback: {}, silent: true } - ) - const completionMap = safeJsonParse>( - modelSettings.CompletionRatio, - { fallback: {}, silent: true } - ) - const imageMap = safeJsonParse>( - modelSettings.ImageRatio, - { fallback: {}, silent: true } - ) - const audioMap = safeJsonParse>( - modelSettings.AudioRatio, - { fallback: {}, silent: true } - ) - const audioCompletionMap = safeJsonParse>( - modelSettings.AudioCompletionRatio, - { fallback: {}, silent: true } - ) - - // Remove old model name entries if model name changed (always, even if no new config) - if (isEditing && oldModelName && oldModelName !== finalModelName) { - delete priceMap[oldModelName] - delete ratioMap[oldModelName] - delete cacheMap[oldModelName] - delete completionMap[oldModelName] - delete imageMap[oldModelName] - delete audioMap[oldModelName] - delete audioCompletionMap[oldModelName] - } - - // Remove current model name from all maps first (always, to handle mode switches or clearing) - // This ensures stale entries are removed even when user clears all fields - delete priceMap[finalModelName] - delete ratioMap[finalModelName] - delete cacheMap[finalModelName] - delete completionMap[finalModelName] - delete imageMap[finalModelName] - delete audioMap[finalModelName] - delete audioCompletionMap[finalModelName] - - // Only add new entries if user provided new configuration - if (hasRatioConfig) { - if ( - pricingMode === 'per-request' && - values.price && - values.price !== '' - ) { - priceMap[finalModelName] = parseFloat(values.price) - } else if (pricingMode === 'per-token') { - if (values.ratio && values.ratio !== '') { - ratioMap[finalModelName] = parseFloat(values.ratio) - } - if (values.cacheRatio && values.cacheRatio !== '') { - cacheMap[finalModelName] = parseFloat(values.cacheRatio) - } - if (values.completionRatio && values.completionRatio !== '') { - completionMap[finalModelName] = parseFloat( - values.completionRatio - ) - } - if (values.imageRatio && values.imageRatio !== '') { - imageMap[finalModelName] = parseFloat(values.imageRatio) - } - if (values.audioRatio && values.audioRatio !== '') { - audioMap[finalModelName] = parseFloat(values.audioRatio) - } - if ( - values.audioCompletionRatio && - values.audioCompletionRatio !== '' - ) { - audioCompletionMap[finalModelName] = parseFloat( - values.audioCompletionRatio - ) - } - } - } - - // Update system options if there are changes - const updates: Array<{ key: string; value: string }> = [] - - const newModelPrice = normalizeJsonString(JSON.stringify(priceMap)) - if ( - newModelPrice !== normalizeJsonString(modelSettings.ModelPrice) - ) { - updates.push({ key: 'ModelPrice', value: newModelPrice }) - } - - const newModelRatio = normalizeJsonString(JSON.stringify(ratioMap)) - if ( - newModelRatio !== normalizeJsonString(modelSettings.ModelRatio) - ) { - updates.push({ key: 'ModelRatio', value: newModelRatio }) - } - - const newCacheRatio = normalizeJsonString(JSON.stringify(cacheMap)) - if ( - newCacheRatio !== normalizeJsonString(modelSettings.CacheRatio) - ) { - updates.push({ key: 'CacheRatio', value: newCacheRatio }) - } - - const newCompletionRatio = normalizeJsonString( - JSON.stringify(completionMap) - ) - if ( - newCompletionRatio !== - normalizeJsonString(modelSettings.CompletionRatio) - ) { - updates.push({ - key: 'CompletionRatio', - value: newCompletionRatio, - }) - } - - const newImageRatio = normalizeJsonString(JSON.stringify(imageMap)) - if ( - newImageRatio !== normalizeJsonString(modelSettings.ImageRatio) - ) { - updates.push({ key: 'ImageRatio', value: newImageRatio }) - } - - const newAudioRatio = normalizeJsonString(JSON.stringify(audioMap)) - if ( - newAudioRatio !== normalizeJsonString(modelSettings.AudioRatio) - ) { - updates.push({ key: 'AudioRatio', value: newAudioRatio }) - } - - const newAudioCompletionRatio = normalizeJsonString( - JSON.stringify(audioCompletionMap) - ) - if ( - newAudioCompletionRatio !== - normalizeJsonString(modelSettings.AudioCompletionRatio) - ) { - updates.push({ - key: 'AudioCompletionRatio', - value: newAudioCompletionRatio, - }) - } - - // Apply all updates (including deletions when clearing fields) - for (const update of updates) { - await updateOption.mutateAsync(update) - } - } - toast.success( isEditing ? 'Model updated successfully' @@ -605,16 +193,7 @@ export function ModelMutateDrawer({ setIsSubmitting(false) } }, - [ - isEditing, - currentRow, - queryClient, - onOpenChange, - pricingMode, - oldModelName, - modelSettings, - updateOption, - ] + [isEditing, currentRow, queryClient, onOpenChange] ) const handleFillEndpointTemplate = (templateKey: string) => { @@ -887,349 +466,6 @@ export function ModelMutateDrawer({ - {/* Pricing Configuration */} -
-

- {t('Pricing Configuration')} -

- -
- - - setPricingMode(value as PricingMode) - } - > -
- - -
-
- - -
-
-
- - {pricingMode === 'per-request' ? ( - ( - - {t('Fixed price (USD)')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t( - 'Cost in USD per request, regardless of tokens used.' - )} - - - - )} - /> - ) : ( - <> -
- - - setPricingSubMode(value as PricingSubMode) - } - > -
- - -
-
- - -
-
-
- - {pricingSubMode === 'ratio' ? ( - <> - ( - - {t('Model ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - if (value) { - setPromptPrice( - (parseFloat(value) * 2).toString() - ) - } else { - setPromptPrice('') - } - } - }} - /> - - - {field.value && !isNaN(parseFloat(field.value)) - ? `Calculated price: $${(parseFloat(field.value) * 2).toFixed(4)} per 1M tokens` - : t('Multiplier for prompt tokens.')} - - - - )} - /> - - ( - - {t('Completion ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - const ratio = form.getValues('ratio') - if (value && ratio) { - const compPrice = - parseFloat(ratio) * - 2 * - parseFloat(value) - setCompletionPrice(compPrice.toString()) - } else { - setCompletionPrice('') - } - } - }} - /> - - - {field.value && - !isNaN(parseFloat(field.value)) && - promptPrice && - !isNaN(parseFloat(promptPrice)) - ? `Calculated price: $${(parseFloat(promptPrice) * parseFloat(field.value)).toFixed(4)} per 1M tokens` - : t('Multiplier for completion tokens.')} - - - - )} - /> - - ) : ( - <> -
-
- - - handlePromptPriceChange(e.target.value) - } - /> -

- {promptPrice && !isNaN(parseFloat(promptPrice)) - ? `Calculated ratio: ${(parseFloat(promptPrice) / 2).toFixed(4)}` - : t('Enter Input price to calculate ratio')} -

-
- -
- - - handleCompletionPriceChange(e.target.value) - } - /> -

- {completionPrice && - !isNaN(parseFloat(completionPrice)) && - promptPrice && - !isNaN(parseFloat(promptPrice)) && - parseFloat(promptPrice) > 0 - ? `Calculated ratio: ${(parseFloat(completionPrice) / parseFloat(promptPrice)).toFixed(4)}` - : t('Enter Completion price to calculate ratio')} -

-
-
- - )} - - - - } - > - {t('Advanced options')} - - - - ( - - {t('Cache ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t('Discount ratio for cache hits.')} - - - - )} - /> - - ( - - {t('Image ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t('Multiplier for image processing.')} - - - - )} - /> - - ( - - {t('Audio ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t('Multiplier for audio inputs.')} - - - - )} - /> - - ( - - {t('Audio completion ratio')} - - { - const value = e.target.value - if (validateNumber(value)) { - field.onChange(value) - } - }} - /> - - - {t('Multiplier for audio outputs.')} - - - - )} - /> - - - - )} -
- - - {/* Status & Sync */}

{t('Status & Sync')}

diff --git a/web/default/src/features/pricing/components/model-card.tsx b/web/default/src/features/pricing/components/model-card.tsx index a8d792bc87e6..e44a32e1e64b 100644 --- a/web/default/src/features/pricing/components/model-card.tsx +++ b/web/default/src/features/pricing/components/model-card.tsx @@ -30,7 +30,12 @@ import { } from '../lib/dynamic-price' import { parseTags } from '../lib/filters' import { isTokenBasedModel } from '../lib/model-helpers' -import { formatPrice, formatRequestPrice } from '../lib/price' +import { + formatPrice, + formatRequestPrice, + formatVideoSecondPrice, + getVideoPriceEntries, +} from '../lib/price' import type { PricingModel, TokenUnit } from '../types' import { ModelPerfBadge, type ModelPerfBadgeData } from './model-perf-badge' @@ -63,6 +68,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { const isDynamicPricing = props.model.billing_mode === 'tiered_expr' && Boolean(props.model.billing_expr) + const isVideoSeconds = props.model.billing_mode === 'video_seconds' const hasCachedPrice = isTokenBased && props.model.cache_ratio != null const dynamicSummary = isDynamicPricing ? getDynamicPricingSummary(props.model, { @@ -76,6 +82,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { const primaryGroup = groups[0] const bottomTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)] + const videoPriceEntries = isVideoSeconds + ? getVideoPriceEntries(props.model).slice(0, 2) + : [] const hiddenCount = Math.max(groups.length - 1, 0) + Math.max(endpoints.length - 2, 0) + @@ -138,6 +147,36 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) { {t('Dynamic Pricing')} ) + ) : isVideoSeconds ? ( + <> + {videoPriceEntries.length > 0 ? ( + videoPriceEntries.map((entry) => ( + + {entry.resolution}{' '} + + {formatVideoSecondPrice( + { + ...props.model, + video_price: { + ...props.model.video_price, + prices: { [entry.resolution]: entry.price }, + }, + }, + showRechargePrice, + priceRate, + usdExchangeRate + )} + + /{t('second')} + + )) + ) : ( + - + )} + ) : isTokenBased ? ( <> @@ -227,15 +266,19 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {

{/* Footer: left metadata and right performance summary share row alignment */} -
+
{primaryGroup && ( - + {primaryGroup} {t('Groups')} )} - - {isTokenBased ? t('Token-based') : t('Per Request')} + + {isVideoSeconds + ? t('Video per-second') + : isTokenBased + ? t('Token-based') + : t('Per Request')} {isDynamicPricing && ( )}
- +
{bottomTags.map((item) => ( - + {item} ))} - - {tokenUnitLabel} - + {!isVideoSeconds && ( + + {tokenUnitLabel} + + )} {hiddenCount > 0 && ( - + +{hiddenCount} )} diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 2746b221e0f2..92e3b6aa66ac 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate, useParams, useSearch } from '@tanstack/react-router' -import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react' +import { ArrowLeft, HeartPulse, Info, Timer } from 'lucide-react' import { useTranslation } from 'react-i18next' import { getLobeIcon } from '@/lib/lobe-icon' import { cn } from '@/lib/utils' @@ -60,20 +60,15 @@ import { } from '../lib/dynamic-price' import { parseTags } from '../lib/filters' import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers' -import { inferModelMetadata } from '../lib/model-metadata' -import { formatFixedPrice, formatGroupPrice } from '../lib/price' -import type { - Modality, - ModelCapability, - PriceType, - PricingModel, - TokenUnit, -} from '../types' +import { + formatFixedPrice, + formatGroupPrice, + formatVideoSecondPrice, + getVideoPriceEntries, +} from '../lib/price' +import type { PriceType, PricingModel, TokenUnit } from '../types' import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' -import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api' -import { ModalityIcons } from './model-details-modalities' import { ModelDetailsPerformance } from './model-details-performance' -import { ModelDetailsQuickStats } from './model-details-quick-stats' // ---------------------------------------------------------------------------- // Local UI helpers @@ -87,87 +82,6 @@ function SectionTitle(props: { children: React.ReactNode }) { ) } -const CAPABILITY_LABEL_KEYS: Record = { - function_calling: 'Function calling', - streaming: 'Streaming', - vision: 'Vision', - json_mode: 'JSON mode', - structured_output: 'Structured output', - reasoning: 'Reasoning', - tools: 'Tools', - system_prompt: 'System prompt', - web_search: 'Web search', - code_interpreter: 'Code interpreter', - caching: 'Prompt caching', - embeddings: 'Embeddings', -} - -function CompactCapabilityList(props: { capabilities: ModelCapability[] }) { - const { t } = useTranslation() - - if (props.capabilities.length === 0) { - return ( - - {t('No capabilities reported for this model.')} - - ) - } - - return ( -
- {props.capabilities.map((capability) => ( - - {t(CAPABILITY_LABEL_KEYS[capability] ?? capability)} - - ))} -
- ) -} - -function CompactModalities(props: { input: Modality[]; output: Modality[] }) { - const { t } = useTranslation() - - return ( -
-
- - {t('Input')} - - -
-
- - {t('Output')} - - -
-
- ) -} - -function ModelSignalsSection(props: { - capabilities: ModelCapability[] - input: Modality[] - output: Modality[] -}) { - const { t } = useTranslation() - - return ( -
- - {t('Capabilities')} / {t('Supported modalities')} - -
- - -
-
- ) -} - function OverviewMetric(props: { icon: React.ComponentType<{ className?: string }> label: string @@ -299,9 +213,11 @@ function ModelHeader(props: { model: PricingModel }) { )} · - {model.quota_type === QUOTA_TYPE_VALUES.TOKEN - ? t('Token-based') - : t('Per Request')} + {model.billing_mode === 'video_seconds' + ? t('Video per-second') + : model.quota_type === QUOTA_TYPE_VALUES.TOKEN + ? t('Token-based') + : t('Per Request')} {model.billing_mode === 'tiered_expr' && model.billing_expr && ( <> @@ -397,6 +313,50 @@ function PriceSection(props: { }, ] + if (props.model.billing_mode === 'video_seconds') { + const entries = getVideoPriceEntries(props.model) + return ( +
+ {t('Base Price')} + {entries.length > 0 ? ( +
+
+ {entries.map((entry) => ( +
+ + {entry.resolution} + + + {formatVideoSecondPrice( + { + ...props.model, + video_price: { + ...props.model.video_price, + prices: { [entry.resolution]: entry.price }, + }, + }, + props.showRechargePrice, + props.priceRate, + props.usdExchangeRate + )} + + / {t('second')} + + +
+ ))} +
+
+ ) : ( +

-

+ )} +
+ ) + } + if (dynamicSummary) { if (dynamicSummary.isSpecialExpression) { return ( @@ -644,6 +604,72 @@ function GroupPricingSection(props: { const thClass = 'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase' + if (props.model.billing_mode === 'video_seconds') { + const videoEntries = getVideoPriceEntries(props.model) + return ( +
+ {t('Pricing by Group')} + +
+ + + + {t('Group')} + {t('Ratio')} + {videoEntries.map((entry) => ( + + {entry.resolution} + + ))} + + + + {availableGroups.map((group) => { + const ratio = props.groupRatio[group] || 1 + return ( + + + + + + {ratio}x + + {videoEntries.map((entry) => ( + + {formatVideoSecondPrice( + { + ...props.model, + video_price: { + ...props.model.video_price, + prices: { [entry.resolution]: entry.price }, + }, + }, + showRechargePrice, + props.priceRate, + props.usdExchangeRate, + ratio + )} + + ))} + + ) + })} + +
+

+ {t('Prices shown per second')} +

+
+
+ ) + } + if (isDynamicPricingModel(props.model)) { const dynamicTiers = getDynamicPricingTiers(props.model) @@ -882,7 +908,7 @@ function GroupPricingSection(props: { ) } -const TAB_VALUES = ['overview', 'performance', 'api'] as const +const TAB_VALUES = ['overview', 'performance'] as const type TabValue = (typeof TAB_VALUES)[number] const TAB_META: Record< @@ -891,7 +917,6 @@ const TAB_META: Record< > = { overview: { icon: Info, labelKey: 'Overview' }, performance: { icon: HeartPulse, labelKey: 'Performance' }, - api: { icon: Code2, labelKey: 'API' }, } export interface ModelDetailsContentProps { @@ -909,7 +934,6 @@ export interface ModelDetailsContentProps { export function ModelDetailsContent(props: ModelDetailsContentProps) { const { t } = useTranslation() const showRechargePrice = props.showRechargePrice ?? false - const metadata = useMemo(() => inferModelMetadata(props.model), [props.model]) const isDynamic = props.model.billing_mode === 'tiered_expr' && @@ -963,27 +987,12 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { /> - - - - - - - -
) diff --git a/web/default/src/features/pricing/components/pricing-columns.tsx b/web/default/src/features/pricing/components/pricing-columns.tsx index c6f26406f2b4..d61c53e11bf5 100644 --- a/web/default/src/features/pricing/components/pricing-columns.tsx +++ b/web/default/src/features/pricing/components/pricing-columns.tsx @@ -37,6 +37,8 @@ import { isTokenBasedModel } from '../lib/model-helpers' import { formatPrice, formatRequestPrice, + formatVideoSecondPrice, + getVideoPriceEntries, stripTrailingZeros, } from '../lib/price' import type { PricingModel, TokenUnit } from '../types' @@ -140,9 +142,10 @@ export function usePricingColumns( header: t('Type'), cell: ({ row }) => { const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN + const isVideo = row.original.billing_mode === 'video_seconds' return ( - {isTokenBased ? t('Token') : t('Request')} + {isVideo ? t('Video') : isTokenBased ? t('Token') : t('Request')} ) }, @@ -216,6 +219,27 @@ export function usePricingColumns( ) } + if (model.billing_mode === 'video_seconds') { + const price = stripTrailingZeros( + formatVideoSecondPrice( + model, + showRechargePrice, + priceRate, + usdExchangeRate + ) + ) + const firstEntry = getVideoPriceEntries(model)[0] + return ( +
+ {price} +
+ / {t('second')} + {firstEntry ? ` 路 ${firstEntry.resolution}` : ''} +
+
+ ) + } + const isTokenBased = isTokenBasedModel(model) if (isTokenBased) { diff --git a/web/default/src/features/pricing/components/pricing-sidebar.tsx b/web/default/src/features/pricing/components/pricing-sidebar.tsx index 99b161e94358..44f77778c7ad 100644 --- a/web/default/src/features/pricing/components/pricing-sidebar.tsx +++ b/web/default/src/features/pricing/components/pricing-sidebar.tsx @@ -199,12 +199,26 @@ export function PricingSidebar(props: PricingSidebarProps) { { value: QUOTA_TYPES.TOKEN, label: quotaTypeLabels[QUOTA_TYPES.TOKEN], - count: countBy(props.models, (model) => model.quota_type === 0), + count: countBy( + props.models, + (model) => model.billing_mode !== 'video_seconds' && model.quota_type === 0 + ), }, { value: QUOTA_TYPES.REQUEST, label: quotaTypeLabels[QUOTA_TYPES.REQUEST], - count: countBy(props.models, (model) => model.quota_type === 1), + count: countBy( + props.models, + (model) => model.billing_mode !== 'video_seconds' && model.quota_type === 1 + ), + }, + { + value: QUOTA_TYPES.VIDEO, + label: quotaTypeLabels[QUOTA_TYPES.VIDEO], + count: countBy( + props.models, + (model) => model.billing_mode === 'video_seconds' + ), }, ] @@ -238,7 +252,10 @@ export function PricingSidebar(props: PricingSidebarProps) { label, count: countBy( props.models, - (model) => model.supported_endpoint_types?.includes(value) ?? false + (model) => + model.supported_endpoint_types?.includes(value) || + (value === ENDPOINT_TYPES.OPENAI_VIDEO && + model.billing_mode === 'video_seconds') ), })), ] diff --git a/web/default/src/features/pricing/constants.ts b/web/default/src/features/pricing/constants.ts index baee2650881e..fdfde592b338 100644 --- a/web/default/src/features/pricing/constants.ts +++ b/web/default/src/features/pricing/constants.ts @@ -48,6 +48,7 @@ export const QUOTA_TYPES = { ALL: 'all', TOKEN: 'token', REQUEST: 'request', + VIDEO: 'video', } as const export type QuotaTypeOption = (typeof QUOTA_TYPES)[keyof typeof QUOTA_TYPES] @@ -60,6 +61,7 @@ export function getQuotaTypeLabels( [QUOTA_TYPES.ALL]: t('All Models'), [QUOTA_TYPES.TOKEN]: t('Token-based'), [QUOTA_TYPES.REQUEST]: t('Per Request'), + [QUOTA_TYPES.VIDEO]: t('Video per-second'), } } diff --git a/web/default/src/features/pricing/hooks/use-pricing-data.ts b/web/default/src/features/pricing/hooks/use-pricing-data.ts index 914f6e63da51..1808676a51ed 100644 --- a/web/default/src/features/pricing/hooks/use-pricing-data.ts +++ b/web/default/src/features/pricing/hooks/use-pricing-data.ts @@ -19,10 +19,12 @@ For commercial licensing, please contact support@quantumnous.com import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { useStatus } from '@/hooks/use-status' +import { useSystemConfig } from '@/hooks/use-system-config' import { getPricing } from '../api' export function usePricingData() { const { status } = useStatus() + const { currency } = useSystemConfig() const { data, isLoading, error, refetch } = useQuery({ queryKey: ['pricing'], @@ -35,10 +37,20 @@ export function usePricingData() { () => Math.max((status?.price as number) ?? 1, 0.001), [status?.price] ) - const usdExchangeRate = useMemo( - () => Math.max((status?.usd_exchange_rate as number) ?? priceRate, 0.001), - [status?.usd_exchange_rate, priceRate] - ) + const usdExchangeRate = useMemo(() => { + if (currency?.quotaDisplayType === 'CNY') { + return Math.max(currency.usdExchangeRate ?? priceRate, 0.001) + } + if (currency?.quotaDisplayType === 'CUSTOM') { + return Math.max(currency.customCurrencyExchangeRate ?? 1, 0.001) + } + return 1 + }, [ + currency?.quotaDisplayType, + currency?.usdExchangeRate, + currency?.customCurrencyExchangeRate, + priceRate, + ]) const models = useMemo(() => { if (!data?.data || !data?.vendors) return [] diff --git a/web/default/src/features/pricing/lib/dynamic-price.ts b/web/default/src/features/pricing/lib/dynamic-price.ts index 616c4c1840d3..320382110769 100644 --- a/web/default/src/features/pricing/lib/dynamic-price.ts +++ b/web/default/src/features/pricing/lib/dynamic-price.ts @@ -17,6 +17,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { formatBillingCurrencyFromUSD } from '@/lib/currency' +import { getWalletCurrencyConfig } from '@/features/wallet/lib' +import { useSystemConfigStore } from '@/stores/system-config-store' import { TOKEN_UNIT_DIVISORS } from '../constants' import type { PricingModel, TokenUnit } from '../types' import { @@ -80,14 +82,22 @@ export function getDynamicDisplayGroupRatio(model: PricingModel): number { return minRatio === Number.POSITIVE_INFINITY ? 1 : minRatio } -function applyRechargeRate( - price: number, - showWithRecharge: boolean, - priceRate: number, - usdExchangeRate: number -): number { - if (!showWithRecharge) return price - return (price * priceRate) / usdExchangeRate +function formatDynamicPaymentCurrency(amount: number): string { + if (Number.isNaN(amount)) return '-' + + const currency = useSystemConfigStore.getState().config.currency + const walletCurrency = getWalletCurrencyConfig( + currency.quotaDisplayType, + currency.usdExchangeRate, + currency.customCurrencySymbol, + currency.customCurrencyExchangeRate + ) + const formatted = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: Math.abs(amount) >= 1 ? 4 : 6, + }).format(amount) + + return `${walletCurrency.paymentSymbol}${formatted}` } export function formatDynamicUnitPrice( @@ -96,18 +106,15 @@ export function formatDynamicUnitPrice( ): string { const groupRatio = options.groupRatioMultiplier ?? 1 const priceRate = options.priceRate ?? 1 - const usdExchangeRate = options.usdExchangeRate ?? 1 const priceUSD = (valuePerMillionTokens * groupRatio) / TOKEN_UNIT_DIVISORS[options.tokenUnit] - const displayPrice = applyRechargeRate( - priceUSD, - options.showRechargePrice ?? false, - priceRate, - usdExchangeRate - ) - return formatBillingCurrencyFromUSD(displayPrice, { + if (options.showRechargePrice) { + return formatDynamicPaymentCurrency(priceUSD * priceRate) + } + + return formatBillingCurrencyFromUSD(priceUSD, { digitsLarge: 4, digitsSmall: 6, abbreviate: false, diff --git a/web/default/src/features/pricing/lib/filters.ts b/web/default/src/features/pricing/lib/filters.ts index 83788dd700f6..bfc11746ba5a 100644 --- a/web/default/src/features/pricing/lib/filters.ts +++ b/web/default/src/features/pricing/lib/filters.ts @@ -78,11 +78,16 @@ export function filterByQuotaType( quotaType: string ): PricingModel[] { if (quotaType === QUOTA_TYPES.ALL) return models + if (quotaType === QUOTA_TYPES.VIDEO) { + return models.filter((m) => m.billing_mode === 'video_seconds') + } const targetType = quotaType === QUOTA_TYPES.TOKEN ? QUOTA_TYPE_VALUES.TOKEN : QUOTA_TYPE_VALUES.REQUEST - return models.filter((m) => m.quota_type === targetType) + return models.filter( + (m) => m.billing_mode !== 'video_seconds' && m.quota_type === targetType + ) } /** @@ -93,6 +98,13 @@ export function filterByEndpointType( endpointType: string ): PricingModel[] { if (endpointType === ENDPOINT_TYPES.ALL) return models + if (endpointType === ENDPOINT_TYPES.OPENAI_VIDEO) { + return models.filter( + (m) => + m.supported_endpoint_types?.includes(endpointType) || + m.billing_mode === 'video_seconds' + ) + } return models.filter((m) => m.supported_endpoint_types?.includes(endpointType) ) @@ -102,6 +114,12 @@ export function filterByEndpointType( * Get model price for sorting */ function getModelPrice(model: PricingModel): number { + if (model.billing_mode === 'video_seconds') { + const prices = Object.values(model.video_price?.prices || {}) + .map(Number) + .filter((price) => Number.isFinite(price) && price > 0) + return prices.length > 0 ? Math.min(...prices) : 0 + } return model.quota_type === 0 ? model.model_ratio : model.model_price || 0 } diff --git a/web/default/src/features/pricing/lib/price.ts b/web/default/src/features/pricing/lib/price.ts index decbd5978cef..37aae851d764 100644 --- a/web/default/src/features/pricing/lib/price.ts +++ b/web/default/src/features/pricing/lib/price.ts @@ -16,7 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { formatCurrencyFromUSD } from '@/lib/currency' +import { + formatBillingCurrencyFromUSD, + type CurrencyFormatOptions, +} from '@/lib/currency' +import { getWalletCurrencyConfig } from '@/features/wallet/lib' +import { useSystemConfigStore } from '@/stores/system-config-store' import { QUOTA_TYPE_VALUES, TOKEN_UNIT_DIVISORS } from '../constants' import type { PricingModel, TokenUnit, PriceType } from '../types' @@ -121,40 +126,55 @@ function hasRatio(value: number | null | undefined): boolean { return value !== undefined && value !== null && Number.isFinite(Number(value)) } -/** - * Apply recharge rate to price - * - * priceRate represents how much users need to recharge (in the display currency) - * to get 1 USD credit. usdExchangeRate is the real exchange rate. - * - * The returned value will be formatted by formatCurrencyFromUSD, which will - * multiply by the display currency's exchange rate. - * - * Examples: - * - * 1. Display currency = USD: - * - Model: 1 USD - * - priceRate = 0.5 (recharge $0.5 to get $1 credit) - * - usdExchangeRate = 1 - * - Return: 1 × 0.5 / 1 = 0.5 - * - formatCurrencyFromUSD(0.5) → $0.5 ✓ - * - * 2. Display currency = CNY: - * - Model: 1 USD - * - priceRate = 4 (recharge ¥4 to get $1 credit) - * - usdExchangeRate = 7 (real rate: 1 USD = ¥7) - * - Return: 1 × 4 / 7 = 0.571 - * - formatCurrencyFromUSD(0.571) → 0.571 × 7 = ¥4 ✓ - * - Normal price: ¥7, Recharge price: ¥4 (cheaper!) - */ -function applyRechargeRate( - price: number, +const PRICE_FORMAT_OPTIONS: CurrencyFormatOptions = { + digitsLarge: 4, + digitsSmall: 6, + abbreviate: false, +} + +const REQUEST_PRICE_FORMAT_OPTIONS: CurrencyFormatOptions = { + digitsLarge: 4, + digitsSmall: 4, + abbreviate: false, +} + +const VIDEO_PRICE_FORMAT_OPTIONS: CurrencyFormatOptions = { + digitsLarge: 4, + digitsSmall: 4, + abbreviate: false, +} + +function formatPaymentCurrency( + amount: number, + options: CurrencyFormatOptions +): string { + if (Number.isNaN(amount)) return '-' + + const currency = useSystemConfigStore.getState().config.currency + const walletCurrency = getWalletCurrencyConfig( + currency.quotaDisplayType, + currency.usdExchangeRate, + currency.customCurrencySymbol, + currency.customCurrencyExchangeRate + ) + const formatted = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: Math.abs(amount) >= 1 ? options.digitsLarge : options.digitsSmall, + }).format(amount) + + return `${walletCurrency.paymentSymbol}${formatted}` +} + +function formatPricingCurrency( + amountUSD: number, showWithRecharge: boolean, priceRate: number, - usdExchangeRate: number -): number { - if (!showWithRecharge) return price - return (price * priceRate) / usdExchangeRate + options: CurrencyFormatOptions +): string { + if (showWithRecharge) { + return formatPaymentCurrency(amountUSD * priceRate, options) + } + return formatBillingCurrencyFromUSD(amountUSD, options) } /** @@ -166,7 +186,7 @@ export function formatPrice( tokenUnit: TokenUnit, showWithRecharge = false, priceRate = 1, - usdExchangeRate = 1 + _usdExchangeRate = 1 ): string { if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) { return '-' @@ -178,20 +198,14 @@ export function formatPrice( const groupRatio = model.group_ratio || {} const minRatio = getMinGroupRatio(enableGroups, groupRatio) - let priceInUSD = calculateTokenPrice(model, type, minRatio) - priceInUSD = applyRechargeRate( + const priceInUSD = + calculateTokenPrice(model, type, minRatio) / TOKEN_UNIT_DIVISORS[tokenUnit] + return formatPricingCurrency( priceInUSD, showWithRecharge, priceRate, - usdExchangeRate + PRICE_FORMAT_OPTIONS ) - - const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit] - return formatCurrencyFromUSD(price, { - digitsLarge: 4, - digitsSmall: 6, - abbreviate: false, - }) } /** @@ -204,7 +218,7 @@ export function formatGroupPrice( tokenUnit: TokenUnit, showWithRecharge = false, priceRate = 1, - usdExchangeRate = 1, + _usdExchangeRate = 1, groupRatio: Record ): string { if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) { @@ -212,21 +226,15 @@ export function formatGroupPrice( } const ratio = groupRatio[group] || 1 - let priceInUSD = calculateTokenPrice(model, type, ratio) + const priceInUSD = + calculateTokenPrice(model, type, ratio) / TOKEN_UNIT_DIVISORS[tokenUnit] - priceInUSD = applyRechargeRate( + return formatPricingCurrency( priceInUSD, showWithRecharge, priceRate, - usdExchangeRate + PRICE_FORMAT_OPTIONS ) - - const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit] - return formatCurrencyFromUSD(price, { - digitsLarge: 4, - digitsSmall: 6, - abbreviate: false, - }) } /** @@ -237,7 +245,7 @@ export function formatFixedPrice( group: string, showWithRecharge = false, priceRate = 1, - usdExchangeRate = 1, + _usdExchangeRate = 1, groupRatio: Record ): string { if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) { @@ -245,20 +253,14 @@ export function formatFixedPrice( } const ratio = groupRatio[group] || 1 - let priceInUSD = (model.model_price || 0) * ratio + const priceInUSD = (model.model_price || 0) * ratio - priceInUSD = applyRechargeRate( + return formatPricingCurrency( priceInUSD, showWithRecharge, priceRate, - usdExchangeRate + REQUEST_PRICE_FORMAT_OPTIONS ) - - return formatCurrencyFromUSD(priceInUSD, { - digitsLarge: 4, - digitsSmall: 4, - abbreviate: false, - }) } /** @@ -268,7 +270,7 @@ export function formatRequestPrice( model: PricingModel, showWithRecharge = false, priceRate = 1, - usdExchangeRate = 1 + _usdExchangeRate = 1 ): string { if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) { return '-' @@ -280,18 +282,39 @@ export function formatRequestPrice( const groupRatio = model.group_ratio || {} const minRatio = getMinGroupRatio(enableGroups, groupRatio) - let priceInUSD = (model.model_price || 0) * minRatio + const priceInUSD = (model.model_price || 0) * minRatio - priceInUSD = applyRechargeRate( + return formatPricingCurrency( priceInUSD, showWithRecharge, priceRate, - usdExchangeRate + REQUEST_PRICE_FORMAT_OPTIONS ) +} - return formatCurrencyFromUSD(priceInUSD, { - digitsLarge: 4, - digitsSmall: 4, - abbreviate: false, - }) +export function getVideoPriceEntries( + model: PricingModel +): Array<{ resolution: string; price: number }> { + const prices = model.video_price?.prices || {} + return Object.entries(prices) + .map(([resolution, price]) => ({ resolution, price: Number(price) })) + .filter((entry) => Number.isFinite(entry.price) && entry.price > 0) + .sort((a, b) => a.price - b.price) +} + +export function formatVideoSecondPrice( + model: PricingModel, + showWithRecharge = false, + priceRate = 1, + _usdExchangeRate = 1, + ratio = 1 +): string { + const first = getVideoPriceEntries(model)[0] + if (!first) return '-' + return formatPricingCurrency( + first.price * ratio, + showWithRecharge, + priceRate, + VIDEO_PRICE_FORMAT_OPTIONS + ) } diff --git a/web/default/src/features/pricing/types.ts b/web/default/src/features/pricing/types.ts index 9e643c913b22..fa9540725da4 100644 --- a/web/default/src/features/pricing/types.ts +++ b/web/default/src/features/pricing/types.ts @@ -53,6 +53,8 @@ export type PricingModel = { billing_mode?: string /** Raw expression describing dynamic / tiered billing */ billing_expr?: string + /** Video per-second pricing by resolution */ + video_price?: VideoPricingConfig /** Pricing version returned by backend, useful for cache busting */ pricing_version?: string /** @@ -71,6 +73,11 @@ export type PricingModel = { capabilities?: ModelCapability[] } +export type VideoPricingConfig = { + base_fps?: number + prices?: Record +} + /** Input/output modalities supported by a model. */ export type Modality = 'text' | 'image' | 'audio' | 'video' | 'file' diff --git a/web/default/src/features/profile/components/language-preferences-card.tsx b/web/default/src/features/profile/components/language-preferences-card.tsx index 969ee84e7cad..411322144ee8 100644 --- a/web/default/src/features/profile/components/language-preferences-card.tsx +++ b/web/default/src/features/profile/components/language-preferences-card.tsx @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { INTERFACE_LANGUAGE_OPTIONS, normalizeInterfaceLanguage, @@ -25,6 +26,7 @@ import { Languages, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' +import { refreshLanguageSensitiveQueries } from '@/lib/i18n-query-refresh' import { Select, SelectContent, @@ -45,6 +47,7 @@ type LanguagePreferencesCardProps = { export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) { const { t, i18n } = useTranslation() + const queryClient = useQueryClient() const { auth } = useAuthStore() const [saving, setSaving] = useState(false) @@ -68,6 +71,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) { setCurrentLanguage(nextLanguage) setSaving(true) await i18n.changeLanguage(nextLanguage) + refreshLanguageSensitiveQueries(queryClient) try { const response = await updateUserLanguage(nextLanguage) @@ -94,6 +98,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) { } catch (_error) { setCurrentLanguage(previousLanguage) await i18n.changeLanguage(previousLanguage) + refreshLanguageSensitiveQueries(queryClient) toast.error(t('Failed to update settings')) } finally { setSaving(false) diff --git a/web/default/src/features/profile/components/tabs/notification-tab.tsx b/web/default/src/features/profile/components/tabs/notification-tab.tsx index 3cbcc2b9b383..545f26d75fe1 100644 --- a/web/default/src/features/profile/components/tabs/notification-tab.tsx +++ b/web/default/src/features/profile/components/tabs/notification-tab.tsx @@ -66,7 +66,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { gotify_token: '', gotify_priority: 5, accept_unset_model_ratio_model: false, - record_ip_log: false, upstream_model_update_notify_enabled: false, }) @@ -94,7 +93,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { gotify_priority: parsed.gotify_priority ?? 5, accept_unset_model_ratio_model: parsed.accept_unset_model_ratio_model || false, - record_ip_log: parsed.record_ip_log || false, upstream_model_update_notify_enabled: parsed.upstream_model_update_notify_enabled || false, }) @@ -365,21 +363,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { />
- {/* Record IP Log */} -
-
- -

- {t('Log IP address for usage and error logs')} -

-
- updateField('record_ip_log', checked)} - /> -
{/* Save Button */} diff --git a/web/default/src/features/profile/types.ts b/web/default/src/features/profile/types.ts index d3fbeee6527e..05d81c94f1dd 100644 --- a/web/default/src/features/profile/types.ts +++ b/web/default/src/features/profile/types.ts @@ -112,7 +112,7 @@ export interface UserSettings { gotify_priority?: number /** Accept unset model ratio model */ accept_unset_model_ratio_model?: boolean - /** Record IP log */ + /** Legacy setting kept for parsing existing user settings */ record_ip_log?: boolean /** Receive upstream model update notifications (admin only) */ upstream_model_update_notify_enabled?: boolean @@ -143,7 +143,6 @@ export interface UpdateUserSettingsRequest { gotify_token?: string gotify_priority?: number accept_unset_model_ratio_model?: boolean - record_ip_log?: boolean upstream_model_update_notify_enabled?: boolean } diff --git a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx index c2748294c9ee..1fe3e3c0c9ea 100644 --- a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx +++ b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx @@ -20,6 +20,16 @@ import { useState, useEffect } from 'react' import { Crown, CalendarClock, Package } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { + formatBillingCurrencyFromUSD, + formatQuotaWithCurrency, +} from '@/lib/currency' +import { useStatus } from '@/hooks/use-status' +import { useSystemConfig } from '@/hooks/use-system-config' +import { + formatWalletCurrencyAmount, + getWalletCurrencyConfig, +} from '@/features/wallet/lib' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { @@ -43,7 +53,10 @@ import { paySubscriptionCreem, paySubscriptionEpay, } from '../../api' -import { formatDuration, formatResetPeriod } from '../../lib' +import { + formatDuration, + formatResetPeriod, +} from '../../lib' import type { PlanRecord } from '../../types' interface PaymentMethod { @@ -65,6 +78,8 @@ interface Props { export function SubscriptionPurchaseDialog(props: Props) { const { t } = useTranslation() + const { status } = useStatus() + const { currency } = useSystemConfig() const [paying, setPaying] = useState(false) const [selectedEpayMethod, setSelectedEpayMethod] = useState('') @@ -90,7 +105,17 @@ export function SubscriptionPurchaseDialog(props: Props) { selectedEpayMethod || t('Select payment method') const totalAmount = Number(plan.total_amount || 0) - const price = Number(plan.price_amount || 0).toFixed(2) + const price = formatBillingCurrencyFromUSD(plan.price_amount) + const walletCurrency = getWalletCurrencyConfig( + currency?.quotaDisplayType, + currency?.usdExchangeRate, + currency?.customCurrencySymbol, + currency?.customCurrencyExchangeRate + ) + const localPaymentAmount = formatWalletCurrencyAmount( + plan.price_amount * ((status?.price as number) || 1), + walletCurrency.paymentSymbol + ) const limitReached = (props.purchaseLimit || 0) > 0 && (props.purchaseCount || 0) >= (props.purchaseLimit || 0) @@ -230,7 +255,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
- {totalAmount > 0 ? totalAmount : t('Unlimited')} + {totalAmount > 0 ? formatQuotaWithCurrency(totalAmount) : t('Unlimited')}
{plan.upgrade_group && ( @@ -244,7 +269,12 @@ export function SubscriptionPurchaseDialog(props: Props) {
{t('Amount Due')} - ${price} +
+
{price}
+
+ {localPaymentAmount} +
+
diff --git a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx index f478cb7539f3..774ff1906dd1 100644 --- a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx +++ b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx @@ -22,7 +22,11 @@ import { useTranslation } from 'react-i18next' import { DataTableColumnHeader } from '@/components/data-table' import { GroupBadge } from '@/components/group-badge' import { StatusBadge } from '@/components/status-badge' -import { formatDuration, formatResetPeriod } from '../lib' +import { + formatDuration, + formatResetPeriod, + formatSubscriptionPrice, +} from '../lib' import type { PlanRecord } from '../types' import { DataTableRowActions } from './data-table-row-actions' @@ -74,7 +78,10 @@ export function useSubscriptionsColumns(): ColumnDef[] { ), cell: ({ row }) => ( - ${Number(row.original.plan.price_amount || 0).toFixed(2)} + {formatSubscriptionPrice( + row.original.plan.price_amount, + row.original.plan.currency + )} ), size: 100, diff --git a/web/default/src/features/subscriptions/lib/format.ts b/web/default/src/features/subscriptions/lib/format.ts index 3d035bfceba4..e2609502a2b8 100644 --- a/web/default/src/features/subscriptions/lib/format.ts +++ b/web/default/src/features/subscriptions/lib/format.ts @@ -20,6 +20,26 @@ import type { TFunction } from 'i18next' import dayjs from '@/lib/dayjs' import type { SubscriptionPlan } from '../types' +export function formatSubscriptionPrice( + amount: number | string | null | undefined, + currency?: string | null +): string { + const numeric = typeof amount === 'number' ? amount : Number(amount || 0) + const formatted = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(Number.isFinite(numeric) ? numeric : 0) + const code = currency?.trim().toUpperCase() || 'USD' + + if (code === 'USD') return `$${formatted}` + if (code === 'CNY' || code === 'RMB') return `¥${formatted}` + if (code === 'EUR') return `€${formatted}` + if (code === 'GBP') return `£${formatted}` + if (code === 'JPY') return `¥${formatted}` + + return `${code} ${formatted}` +} + export function formatDuration( plan: Partial, t: TFunction diff --git a/web/default/src/features/subscriptions/lib/index.ts b/web/default/src/features/subscriptions/lib/index.ts index 783d2d05964d..7a9dd9fe7ae8 100644 --- a/web/default/src/features/subscriptions/lib/index.ts +++ b/web/default/src/features/subscriptions/lib/index.ts @@ -16,7 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -export { formatDuration, formatResetPeriod, formatTimestamp } from './format' +export { + formatDuration, + formatResetPeriod, + formatSubscriptionPrice, + formatTimestamp, +} from './format' export { getPlanFormSchema, PLAN_FORM_DEFAULTS, diff --git a/web/default/src/features/system-settings/api.ts b/web/default/src/features/system-settings/api.ts index 86f4179296c0..7094ffe78d60 100644 --- a/web/default/src/features/system-settings/api.ts +++ b/web/default/src/features/system-settings/api.ts @@ -21,7 +21,9 @@ import type { ConfirmPaymentComplianceResponse, DeleteLogsResponse, FetchUpstreamRatiosRequest, + GenerateAITranslationsResponse, SystemOptionsResponse, + UpdateAITranslationSettingsRequest, UpdateOptionRequest, UpdateOptionResponse, UpstreamChannelsResponse, @@ -60,6 +62,23 @@ export async function resetModelRatios() { return res.data } +export async function generateAITranslations() { + const res = await api.post( + '/api/option/ai_translation/generate' + ) + return res.data +} + +export async function updateAITranslationSettings( + request: UpdateAITranslationSettingsRequest +) { + const res = await api.put( + '/api/option/ai_translation/settings', + request + ) + return res.data +} + export async function getUpstreamChannels() { const res = await api.get( '/api/ratio_sync/channels' diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index 3b006f772e54..364fd485e05b 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -49,6 +49,7 @@ const defaultBillingSettings: BillingSettings = { ExposeRatioEnabled: false, 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', + 'billing_setting.video_price': '{}', 'tool_price_setting.prices': '{}', TopupGroupRatio: '', GroupRatio: '', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index ee829e23cf07..4f13e96022a7 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -37,6 +37,7 @@ const getModelDefaults = (settings: BillingSettings) => ({ ExposeRatioEnabled: settings.ExposeRatioEnabled, BillingMode: settings['billing_setting.billing_mode'], BillingExpr: settings['billing_setting.billing_expr'], + VideoPrice: settings['billing_setting.video_price'], }) const getGroupDefaults = (settings: BillingSettings) => ({ diff --git a/web/default/src/features/system-settings/hooks/use-update-option.ts b/web/default/src/features/system-settings/hooks/use-update-option.ts index f01bf5da8b53..6532fde654ea 100644 --- a/web/default/src/features/system-settings/hooks/use-update-option.ts +++ b/web/default/src/features/system-settings/hooks/use-update-option.ts @@ -27,6 +27,11 @@ const STATUS_RELATED_KEYS = [ 'theme.frontend', 'HeaderNavModules', 'SidebarModulesAdmin', + 'AITranslationEnabled', + 'AITranslationBaseURL', + 'AITranslationAPIKey', + 'AITranslationModel', + 'AITranslationTimeoutSeconds', 'Notice', 'LogConsumeEnabled', 'QuotaPerUnit', diff --git a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx index b7ac1f6a7ed6..8fd13ffd3650 100644 --- a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx +++ b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx @@ -31,6 +31,7 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -47,18 +48,42 @@ const headerNavSchema = z.object({ pricingRequireAuth: z.boolean(), rankingsEnabled: z.boolean(), rankingsRequireAuth: z.boolean(), + rankingsDisplayMultiplier: z.number().min(0), + rankingsDisplayJitterRatio: z.number().min(0), docs: z.boolean(), about: z.boolean(), }) type HeaderNavFormValues = z.infer +type HeaderNavBooleanKey = Exclude< + keyof HeaderNavFormValues, + 'rankingsDisplayMultiplier' | 'rankingsDisplayJitterRatio' +> type HeaderNavigationSectionProps = { config: HeaderNavModulesConfig initialSerialized: string + rankingsDisplayMultiplier: string + rankingsDisplayJitterRatio: string } -const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ +const parseDisplayNumber = (value: string | undefined, fallback: number) => { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} + +const toOptionNumber = (value: number) => { + if (!Number.isFinite(value) || value < 0) { + return '0' + } + return String(value) +} + +const toFormValues = ( + config: HeaderNavModulesConfig, + multiplier: string, + jitterRatio: string +): HeaderNavFormValues => ({ home: config.home === undefined ? HEADER_NAV_DEFAULT.home : Boolean(config.home), console: @@ -81,6 +106,8 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ config.rankings?.requireAuth === undefined ? HEADER_NAV_DEFAULT.rankings.requireAuth : Boolean(config.rankings.requireAuth), + rankingsDisplayMultiplier: parseDisplayNumber(multiplier, 1), + rankingsDisplayJitterRatio: parseDisplayNumber(jitterRatio, 0), docs: config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs), about: @@ -92,10 +119,20 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ export function HeaderNavigationSection({ config, initialSerialized, + rankingsDisplayMultiplier, + rankingsDisplayJitterRatio, }: HeaderNavigationSectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() - const formDefaults = useMemo(() => toFormValues(config), [config]) + const formDefaults = useMemo( + () => + toFormValues( + config, + rankingsDisplayMultiplier, + rankingsDisplayJitterRatio + ), + [config, rankingsDisplayJitterRatio, rankingsDisplayMultiplier] + ) const form = useForm({ resolver: zodResolver(headerNavSchema), @@ -126,22 +163,47 @@ export function HeaderNavigationSection({ } const serialized = serializeHeaderNavModules(payload) - if (serialized === initialSerialized) { - return + const updates: Array<{ key: string; value: string }> = [] + if (serialized !== initialSerialized) { + updates.push({ + key: 'HeaderNavModules', + value: serialized, + }) + } + + const nextMultiplier = toOptionNumber(values.rankingsDisplayMultiplier) + if ( + nextMultiplier !== + toOptionNumber(parseDisplayNumber(rankingsDisplayMultiplier, 1)) + ) { + updates.push({ + key: 'RankingsDisplayMultiplier', + value: nextMultiplier, + }) } - await updateOption.mutateAsync({ - key: 'HeaderNavModules', - value: serialized, - }) + const nextJitterRatio = toOptionNumber(values.rankingsDisplayJitterRatio) + if ( + nextJitterRatio !== + toOptionNumber(parseDisplayNumber(rankingsDisplayJitterRatio, 0)) + ) { + updates.push({ + key: 'RankingsDisplayJitterRatio', + value: nextJitterRatio, + }) + } + + for (const update of updates) { + await updateOption.mutateAsync(update) + } } const resetToDefault = () => { - form.reset(toFormValues(HEADER_NAV_DEFAULT)) + form.reset(toFormValues(HEADER_NAV_DEFAULT, '1', '0')) } const simpleModules: Array<{ - key: keyof HeaderNavFormValues + key: HeaderNavBooleanKey title: string description: string }> = [ @@ -168,8 +230,8 @@ export function HeaderNavigationSection({ ] const accessModules: Array<{ - enabledKey: keyof HeaderNavFormValues - requireAuthKey: keyof HeaderNavFormValues + enabledKey: HeaderNavBooleanKey + requireAuthKey: HeaderNavBooleanKey requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled' title: string description: string @@ -287,6 +349,81 @@ export function HeaderNavigationSection({ ))}
+
+
+

+ {t('Rankings display values')} +

+

+ {t( + 'Only changes the public rankings display. Raw usage logs and billing stay unchanged.' + )} +

+
+
+ ( + + {t('Display multiplier')} + + + field.onChange(event.currentTarget.valueAsNumber) + } + /> + + + {t( + 'Displayed value equals raw value multiplied by this number.' + )} + + + + )} + /> + ( + + {t('Random jitter ratio')} + + + field.onChange(event.currentTarget.valueAsNumber) + } + /> + + + {t( + 'Adds stable positive random noise. Example: 0.05 means up to 5%.' + )} + + + + )} + /> +
+
+
+
+
+ + + + {t('Resolution')} + {t('Price per second')} + + {t('Actions')} + + + + + {props.rows.map((row) => ( + + + + updateRow(row.id, 'resolution', event.target.value) + } + /> + + + + $ + { + const value = event.target.value + if (numericDraftRegex.test(value)) { + updateRow(row.id, 'price', value) + } + }} + /> + + {t('/ sec')} + + + + + + + + ))} + +
+
+ + + ) +} diff --git a/web/default/src/features/system-settings/models/model-ratio-form.tsx b/web/default/src/features/system-settings/models/model-ratio-form.tsx index f87d9b945209..3f5a39e543ad 100644 --- a/web/default/src/features/system-settings/models/model-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-form.tsx @@ -46,6 +46,7 @@ type ModelFormValues = { ExposeRatioEnabled: boolean BillingMode: string BillingExpr: string + VideoPrice: string } type ModelRatioFormProps = { @@ -112,10 +113,12 @@ export const ModelRatioForm = memo(function ModelRatioForm({ audioCompletionRatio={form.watch('AudioCompletionRatio')} billingMode={form.watch('BillingMode')} billingExpr={form.watch('BillingExpr')} + videoPrice={form.watch('VideoPrice')} onChange={(field, value) => { const fieldMap: Record = { 'billing_setting.billing_mode': 'BillingMode', 'billing_setting.billing_expr': 'BillingExpr', + 'billing_setting.video_price': 'VideoPrice', } const formField = fieldMap[field] || (field as keyof ModelFormValues) @@ -314,6 +317,25 @@ export const ModelRatioForm = memo(function ModelRatioForm({ )} /> + ( + + {t('Video per-second pricing')} + +