diff --git a/.github/workflows/carmin-ghcr-image.yml b/.github/workflows/carmin-ghcr-image.yml new file mode 100644 index 00000000000..7a4a4e0b72c --- /dev/null +++ b/.github/workflows/carmin-ghcr-image.yml @@ -0,0 +1,62 @@ +name: Carmin GHCR image + +on: + push: + branches: + - video-task-result-fix + workflow_dispatch: + +env: + IMAGE_NAME: ghcr.io/carminback/new-api + +jobs: + build: + name: Build and push Docker image + runs-on: ubuntu-24.04-arm + permissions: + contents: read + packages: write + + steps: + - name: Check out + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 1 + + - name: Write version + id: version + run: | + VERSION="carmin-$(date +'%Y%m%d')-${GITHUB_SHA::7}" + echo "$VERSION" > VERSION + echo "value=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=video-task-result-fix + type=raw,value=${{ steps.version.outputs.value }} + type=sha,prefix=sha- + + - name: Build and push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/controller/aistarslab_sync.go b/controller/aistarslab_sync.go new file mode 100644 index 00000000000..bf9e5b657fa --- /dev/null +++ b/controller/aistarslab_sync.go @@ -0,0 +1,39 @@ +package controller + +import ( + "errors" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +func SyncAistarsLabConfig(c *gin.Context) { + var req service.AistarsLabSyncRequest + if c.Request.Body != nil { + err := common.DecodeJson(c.Request.Body, &req) + if err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + } + result, err := service.SyncAistarsLabConfig(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": result, + }) +} diff --git a/controller/image_generation.go b/controller/image_generation.go new file mode 100644 index 00000000000..05cb13010c6 --- /dev/null +++ b/controller/image_generation.go @@ -0,0 +1,52 @@ +package controller + +import ( + "net/http" + "os" + "strconv" + + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +func GetImageGenerationContent(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + c.Status(http.StatusNotFound) + return + } + + record, err := model.GetImageGenerationByID(id) + if err != nil || record == nil { + c.Status(http.StatusNotFound) + return + } + + expires, err := strconv.ParseInt(c.Query("expires"), 10, 64) + if err != nil || !model.ValidateImageGenerationContentSignature(record, expires, c.Query("signature")) { + c.Status(http.StatusUnauthorized) + return + } + if record.Status != model.ImageGenerationStatusSuccess || record.FilePath == "" { + c.Status(http.StatusGone) + return + } + + absolutePath := service.GetImageGenerationAbsolutePath(record) + if absolutePath == "" { + c.Status(http.StatusGone) + return + } + if _, err := os.Stat(absolutePath); err != nil { + c.Status(http.StatusGone) + return + } + + if record.MimeType != "" { + c.Header("Content-Type", record.MimeType) + } + c.Header("Cache-Control", "private, max-age=3600") + c.File(absolutePath) +} diff --git a/controller/image_generation_test.go b/controller/image_generation_test.go new file mode 100644 index 00000000000..b16b1119d1e --- /dev/null +++ b/controller/image_generation_test.go @@ -0,0 +1,86 @@ +package controller + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupImageGenerationControllerTestDB(t *testing.T) *gorm.DB { + t.Helper() + + gin.SetMode(gin.TestMode) + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + model.DB = db + model.LOG_DB = db + require.NoError(t, db.AutoMigrate(&model.ImageGeneration{})) + + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func TestGetImageGenerationContentRequiresValidSignature(t *testing.T) { + db := setupImageGenerationControllerTestDB(t) + + storageDir := t.TempDir() + t.Setenv("IMAGE_GENERATION_STORAGE_DIR", storageDir) + + relativePath := filepath.Join("20260710", "user-1", "image.png") + absolutePath := filepath.Join(storageDir, relativePath) + require.NoError(t, os.MkdirAll(filepath.Dir(absolutePath), 0750)) + require.NoError(t, os.WriteFile(absolutePath, []byte("png-data"), 0600)) + + record := &model.ImageGeneration{ + UserId: 1, + RequestId: "req_image", + FilePath: relativePath, + MimeType: "image/png", + Status: model.ImageGenerationStatusSuccess, + CreatedAt: time.Now().Unix(), + ExpireAt: time.Now().Add(time.Hour).Unix(), + } + require.NoError(t, db.Create(record).Error) + + router := gin.New() + router.GET("/api/image-generations/:id/content", GetImageGenerationContent) + + missingSignature := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/image-generations/%d/content", record.Id), nil) + router.ServeHTTP(missingSignature, req) + require.Equal(t, http.StatusUnauthorized, missingSignature.Code) + + expires := record.ExpireAt + signature := model.GenerateImageGenerationContentSignature(record, expires) + valid := httptest.NewRecorder() + req = httptest.NewRequest( + http.MethodGet, + fmt.Sprintf("/api/image-generations/%d/content?expires=%d&signature=%s", record.Id, expires, signature), + nil, + ) + router.ServeHTTP(valid, req) + require.Equal(t, http.StatusOK, valid.Code) + require.Equal(t, "png-data", valid.Body.String()) +} diff --git a/controller/midjourney.go b/controller/midjourney.go index 69aa5ccd431..22a3c8fd65f 100644 --- a/controller/midjourney.go +++ b/controller/midjourney.go @@ -265,12 +265,14 @@ func GetAllMidjourney(c *gin.Context) { EndTimestamp: c.Query("end_timestamp"), } - items := model.GetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) - total := model.CountAllTasks(queryParams) + items := model.GetAllDrawingLogs(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.CountAllDrawingLogs(queryParams) if setting.MjForwardUrlEnabled { for i, midjourney := range items { - midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + if midjourney.Id > 0 { + midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + } items[i] = midjourney } } @@ -290,12 +292,14 @@ func GetUserMidjourney(c *gin.Context) { EndTimestamp: c.Query("end_timestamp"), } - items := model.GetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) - total := model.CountAllUserTask(userId, queryParams) + items := model.GetAllUserDrawingLogs(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.CountAllUserDrawingLogs(userId, queryParams) if setting.MjForwardUrlEnabled { for i, midjourney := range items { - midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + if midjourney.Id > 0 { + midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + } items[i] = midjourney } } diff --git a/controller/relay.go b/controller/relay.go index c97ab45b4ac..b3b9dfce386 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -22,6 +22,7 @@ import ( "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" "github.com/bytedance/gopkg/util/gopool" @@ -581,7 +582,7 @@ func RelayTask(c *gin.Context) { ModelRatio: relayInfo.PriceData.ModelRatio, OtherRatios: relayInfo.PriceData.OtherRatios, OriginModelName: relayInfo.OriginModelName, - PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice, + PerCallBilling: ratio_setting.IsTaskPerItemBilling(relayInfo.OriginModelName), } task.Quota = result.Quota task.Data = result.TaskData @@ -633,6 +634,9 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, if taskErr.StatusCode == http.StatusBadRequest { return false } + if taskErr.StatusCode == http.StatusForbidden { + return false + } if taskErr.StatusCode == 408 { // azure处理超时不重试 return false diff --git a/controller/relay_retry_test.go b/controller/relay_retry_test.go new file mode 100644 index 00000000000..00a8de668c9 --- /dev/null +++ b/controller/relay_retry_test.go @@ -0,0 +1,19 @@ +package controller + +import ( + "net/http" + "testing" + + "github.com/QuantumNous/new-api/dto" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestShouldRetryTaskRelaySkipsForbidden(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(nil) + + retry := shouldRetryTaskRelay(ctx, 19, &dto.TaskError{StatusCode: http.StatusForbidden}, 5) + + require.False(t, retry) +} diff --git a/controller/topup.go b/controller/topup.go index 86d361a349c..4ef20ac44ea 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -117,6 +117,7 @@ func GetTopUpInfo(c *gin.Context) { type EpayRequest struct { Amount int64 `json:"amount"` PaymentMethod string `json:"payment_method"` + ReturnUrl string `json:"return_url,omitempty"` } type AmountRequest struct { @@ -218,7 +219,15 @@ func RequestEpay(c *gin.Context) { } callBackAddress := service.GetCallbackAddress() - returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log") + returnUrlValue := system_setting.ServerAddress + "/console/log" + if req.ReturnUrl != "" { + if err := common.ValidateRedirectURL(req.ReturnUrl); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "支付完成重定向URL不在可信任域名列表中", "data": ""}) + return + } + returnUrlValue = req.ReturnUrl + } + returnUrl, _ := url.Parse(returnUrlValue) notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) diff --git a/docs/grok-video-api.md b/docs/grok-video-api.md new file mode 100644 index 00000000000..08ad1c6ad21 --- /dev/null +++ b/docs/grok-video-api.md @@ -0,0 +1,239 @@ +# Grok Video Generation API + +This document describes how to call the Grok video generation models through the NewAPI-compatible video endpoint. + +> Note: The expected production base URL is usually `https://token.mewinyou.shop`. +> If using `https://tokne.mewinyou.shop`, confirm that this domain is intentionally configured. The spelling differs. + +## Base URL + +```text +https://token.mewinyou.shop +``` + +## Create Video + +```http +POST /v1/video/generations +``` + +### Headers + +```http +Authorization: Bearer +Content-Type: application/json +``` + +### Request Body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `model` | string | Yes | Model ID. Use `grok-image-video` or `grok-video-1.5`. | +| `prompt` | string | Yes | Video generation prompt. | +| `seconds` | integer or string | No | Video duration in seconds. Default is `4`. | +| `aspect_ratio` | string | No | Output video aspect ratio. | +| `resolution` | string | No | Output video resolution. | +| `image_urls` | string[] | No | Reference images. Supports HTTPS image URLs and Base64 Data URLs. | + +## Supported Models + +### `grok-image-video` + +Supports: + +- Text to video +- Single image to video +- Multi-image to video + +Supported `aspect_ratio` values: + +```text +1:1 +16:9 +9:16 +4:3 +3:4 +3:2 +2:3 +``` + +Reference image behavior: + +| `image_urls` count | Mode | +| ---: | --- | +| 0 | Text to video | +| 1 | Image to video | +| 2 or more | Multi-image video | + +### `grok-video-1.5` + +Supports: + +- Image to video only + +Restrictions: + +- Exactly one reference image is required. +- Multiple reference images are not supported. + +Supported `aspect_ratio` values: + +```text +16:9 +9:16 +``` + +## Resolution + +Supported `resolution` values: + +```text +720p +480p +``` + +## Examples + +### Text to Video + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "grok-image-video", + "prompt": "A futuristic city at sunset, cinematic camera movement, neon reflections", + "seconds": 4, + "aspect_ratio": "16:9", + "resolution": "720p" + }' +``` + +### Single Image to Video + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "grok-image-video", + "prompt": "Animate this image with subtle camera movement and natural lighting", + "seconds": 4, + "aspect_ratio": "16:9", + "resolution": "720p", + "image_urls": [ + "https://example.com/image.png" + ] + }' +``` + +### Multi-image to Video + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "grok-image-video", + "prompt": "Merge these references into a cinematic video with smooth transitions", + "seconds": 4, + "aspect_ratio": "16:9", + "resolution": "720p", + "image_urls": [ + "https://example.com/ref1.png", + "https://example.com/ref2.png" + ] + }' +``` + +### `grok-video-1.5` + +`grok-video-1.5` requires exactly one reference image. + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "grok-video-1.5", + "prompt": "Make the character smile and slightly turn toward the camera", + "seconds": 4, + "aspect_ratio": "16:9", + "resolution": "720p", + "image_urls": [ + "https://example.com/image.png" + ] + }' +``` + +## Success Response + +```json +{ + "id": "task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "task_id": "task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "object": "video", + "model": "grok-image-video", + "status": "queued", + "progress": 0, + "created_at": 1780000000 +} +``` + +`id` and `task_id` are identical. Either value can be used to query task status. + +## Query Task + +```http +GET /v1/video/generations/{task_id} +``` + +Example: + +```bash +curl --location --request GET 'https://token.mewinyou.shop/v1/video/generations/task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'Authorization: Bearer ' +``` + +Poll every 3 to 5 seconds until the task reaches a final state. + +## Task Status + +| Status | Description | +| --- | --- | +| `queued` | Waiting in queue. | +| `processing` | Video is being generated. | +| `succeeded` | Generation completed successfully. | +| `failed` | Generation failed. | + +## Completed Response + +When the task succeeds, the response should include the generated video URL in the task result payload. The exact field may depend on upstream response normalization, but the final NewAPI response should expose a successful task status and a result URL. + +Example shape: + +```json +{ + "id": "task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "task_id": "task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "object": "video", + "model": "grok-image-video", + "status": "succeeded", + "progress": 100, + "result_url": "https://example.com/generated-video.mp4" +} +``` + +## Notes + +- Use `grok-image-video` for text-to-video, image-to-video, and multi-image video. +- Use `grok-video-1.5` only when exactly one reference image is provided. +- `image_urls` accepts HTTPS URLs and Base64 Data URLs. +- `resolution` supports `720p` and `480p`. +- `aspect_ratio` support depends on the selected model. +- Authentication is always required: + +```http +Authorization: Bearer +``` + diff --git a/docs/grok-video-api.zh-CN.md b/docs/grok-video-api.zh-CN.md new file mode 100644 index 00000000000..80635207265 --- /dev/null +++ b/docs/grok-video-api.zh-CN.md @@ -0,0 +1,239 @@ +# Grok 视频生成接口文档 + +本文档说明如何通过 NewAPI 兼容的视频接口调用 Grok 视频生成模型。 + +> 注意:当前生产地址通常是 `https://token.mewinyou.shop`。 +> 如果看到 `https://tokne.mewinyou.shop`,请先确认是否为有意配置;这两个域名拼写不同。 + +## Base URL + +```text +https://token.mewinyou.shop +``` + +## 创建视频任务 + +```http +POST /v1/video/generations +``` + +### 请求头 + +```http +Authorization: Bearer +Content-Type: application/json +``` + +### 请求参数 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `model` | string | 是 | 模型 ID,支持 `grok-image-video` 或 `grok-video-1.5`。 | +| `prompt` | string | 是 | 视频生成提示词。 | +| `seconds` | integer 或 string | 否 | 视频时长,单位秒,默认 `4`。 | +| `aspect_ratio` | string | 否 | 视频宽高比。 | +| `resolution` | string | 否 | 视频分辨率。 | +| `image_urls` | string[] | 否 | 参考图片,支持 HTTPS 图片地址或 Base64 Data URL。 | + +## 支持模型 + +### `grok-image-video` + +支持能力: + +- 文生视频 +- 单图生视频 +- 多图生视频 + +支持的 `aspect_ratio`: + +```text +1:1 +16:9 +9:16 +4:3 +3:4 +3:2 +2:3 +``` + +参考图数量与模式: + +| `image_urls` 数量 | 生成模式 | +| ---: | --- | +| 0 | 文生视频 | +| 1 | 单图生视频 | +| 2 张或更多 | 多图生视频 | + +### `grok-video-1.5` + +支持能力: + +- 仅支持图生视频 + +限制: + +- 必须传且只能传 1 张参考图。 +- 不支持多图。 + +支持的 `aspect_ratio`: + +```text +16:9 +9:16 +``` + +## 分辨率 + +支持的 `resolution`: + +```text +720p +480p +``` + +## 请求示例 + +### 文生视频 + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "grok-image-video", + "prompt": "A futuristic city at sunset, cinematic camera movement, neon reflections", + "seconds": 4, + "aspect_ratio": "16:9", + "resolution": "720p" + }' +``` + +### 单图生视频 + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "grok-image-video", + "prompt": "Animate this image with subtle camera movement and natural lighting", + "seconds": 4, + "aspect_ratio": "16:9", + "resolution": "720p", + "image_urls": [ + "https://example.com/image.png" + ] + }' +``` + +### 多图生视频 + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "grok-image-video", + "prompt": "Merge these references into a cinematic video with smooth transitions", + "seconds": 4, + "aspect_ratio": "16:9", + "resolution": "720p", + "image_urls": [ + "https://example.com/ref1.png", + "https://example.com/ref2.png" + ] + }' +``` + +### `grok-video-1.5` + +`grok-video-1.5` 必须传 exactly one reference image,也就是只能传 1 张参考图。 + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "grok-video-1.5", + "prompt": "Make the character smile and slightly turn toward the camera", + "seconds": 4, + "aspect_ratio": "16:9", + "resolution": "720p", + "image_urls": [ + "https://example.com/image.png" + ] + }' +``` + +## 创建成功响应 + +```json +{ + "id": "task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "task_id": "task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "object": "video", + "model": "grok-image-video", + "status": "queued", + "progress": 0, + "created_at": 1780000000 +} +``` + +`id` 和 `task_id` 相同,查询任务状态时使用任意一个即可。 + +## 查询任务状态 + +```http +GET /v1/video/generations/{task_id} +``` + +示例: + +```bash +curl --location --request GET 'https://token.mewinyou.shop/v1/video/generations/task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'Authorization: Bearer ' +``` + +建议每 3 到 5 秒轮询一次,直到任务进入最终状态。 + +## 任务状态 + +| 状态 | 说明 | +| --- | --- | +| `queued` | 排队中。 | +| `processing` | 生成中。 | +| `succeeded` | 生成成功。 | +| `failed` | 生成失败。 | + +## 成功完成响应 + +任务成功后,响应中应包含生成视频地址。具体字段可能取决于上游响应结构和 NewAPI 的归一化逻辑,但最终应能看到成功状态和视频结果地址。 + +示例结构: + +```json +{ + "id": "task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "task_id": "task_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "object": "video", + "model": "grok-image-video", + "status": "succeeded", + "progress": 100, + "result_url": "https://example.com/generated-video.mp4" +} +``` + +## 注意事项 + +- `grok-image-video` 可用于文生视频、单图生视频、多图生视频。 +- `grok-video-1.5` 仅用于单图生视频,必须传且只能传 1 张图。 +- `image_urls` 支持 HTTPS URL 和 Base64 Data URL。 +- `resolution` 支持 `720p` 和 `480p`。 +- `aspect_ratio` 支持范围取决于具体模型。 +- 所有请求都需要鉴权: + +```http +Authorization: Bearer +``` + diff --git a/docs/seedance-video-integration.md b/docs/seedance-video-integration.md new file mode 100644 index 00000000000..4832c93f78b --- /dev/null +++ b/docs/seedance-video-integration.md @@ -0,0 +1,221 @@ +# Seedance Video Integration + +This document describes the local NewAPI video model aliases used for Seedance channels. +It is intended for future Codex maintenance and operational handoff. + +## Public Endpoint + +Submit a video generation task: + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "seedance-720p-fast-c37", + "prompt": "海边日落,镜头缓慢向前推进,电影感,柔和光线", + "duration": 4, + "resolution": "720p", + "size": "16:9", + "mode_type": "text2video", + "n": 1 + }' +``` + +Poll task status: + +```bash +curl --location 'https://token.mewinyou.shop/v1/video/generations/' \ + --header 'Authorization: Bearer ' +``` + +Users must call the public alias model names listed below. Do not expose or ask users to call +raw upstream model names such as `12:seedance-2.0-720p`. + +## Public Models + +| Public model | Upstream mapped model | Unit | Current sell price | +| --- | --- | --- | ---: | +| `seedance-720p-fast-c37` | `37:seedance-2.0-720p-fast` | per item | 3.90 | +| `seedance-720p-c37` | `37:seedance-2.0-720p` | per item | 5.20 | +| `seedance-480p-fast-c13` | `13:seedance-2.0-480p-fast` | per second | 0.39 | +| `seedance-480p-c13` | `13:seedance-2.0-480p` | per second | 0.47 | +| `seedance-480p-fast-c36` | `36:seedance-2.0-480p-fast` | per second | 0.39 | +| `seedance-720p-fast-c12` | `12:seedance-2.0-720p-fast` | per second | 0.58 | +| `seedance-720p-c12` | `12:seedance-2.0-720p` | per second | 0.68 | +| `seedance-720p-c33` | `33:seedance-2.0-720p` | per second | 0.68 | +| `seedance-720p-c29` | `29:seedance-2.0-720p` | per second | 0.68 | +| `seedance-1080p-c30` | `30:seedance-2.0-1080p` | per second | 1.04 | +| `seedance-720p-c31` | `31:seedance-2.0-720p` | per item | 9.75 | +| `seedance-720p-fast-c8` | `8:seedance-2.0-720p-fast` | per item | 8.19 | +| `seedance-720p-c8` | `8:seedance-2.0-720p` | per item | 9.75 | +| `seedance-720p-fast-c35` | `35:seedance-2.0-720p-fast` | per item | 8.19 | +| `seedance-720p-fast-4img-c18` | `18:seedance-2.0-720p-fast-4img` | per item | 6.24 | +| `seedance-720p-4img-c18` | `18:seedance-2.0-720p-4img` | per item | 7.80 | +| `seedance-720p-c17` | `17:seedance-2.0-720p` | per second | 0.68 | + +The prices above include the currently configured 30% markup over the upstream cost list. +Do not divide these numbers by an exchange rate before writing `ModelPrice`. + +## Billing Rules + +NewAPI uses `ModelPrice` for all public Seedance aliases. + +Per-second models are not listed in `TASK_PRICE_PATCH`, so their final quota is: + +```text +ModelPrice * duration * group_ratio +``` + +Per-item models must be listed in `TASK_PRICE_PATCH`, so their final quota is: + +```text +ModelPrice * group_ratio +``` + +The current `TASK_PRICE_PATCH` value should include only the per-item aliases: + +```text +seedance-720p-fast-c37, +seedance-720p-c37, +seedance-720p-c31, +seedance-720p-fast-c8, +seedance-720p-c8, +seedance-720p-fast-c35, +seedance-720p-fast-4img-c18, +seedance-720p-4img-c18 +``` + +Do not add per-second aliases to `TASK_PRICE_PATCH`. + +## Model Marketplace + +The model marketplace should show the 17 public aliases under vendor `即梦`. + +Display metadata: + +- Vendor: `即梦` +- Icon: `Jimeng.Color` +- Public alias models have `sync_official = 0`. +- Per-item aliases are tagged with `按条`. +- Per-second aliases are tagged with `按秒`. + +The local frontend/backend patch uses `quota_type = 2` for marketplace display of per-second fixed-price models. This is display-only and does not drive runtime billing. + +Raw upstream Seedance models are intentionally hidden: + +```text +12:seedance-2.0-720p +12:seedance-2.0-720p-fast +13:seedance-2.0-480p +13:seedance-2.0-480p-fast +17:seedance-2.0-720p +18:seedance-2.0-720p-4img +18:seedance-2.0-720p-fast-4img +19:seedance-2.0-720p +19:seedance-2.0-720p-fast +26:seedance-2.0 +29:seedance-2.0-1080p +29:seedance-2.0-720p +30:seedance-2.0-1080p +31:seedance-2.0-720p +33:seedance-2.0-720p +33:seedance-2.0-720p-fast +35:seedance-2.0-720p-fast +36:seedance-2.0-480p-fast +37:seedance-2.0-720p +37:seedance-2.0-720p-fast +8:seedance-2.0-720p +8:seedance-2.0-720p-fast +``` + +They should not appear in `/v1/models` or `/api/pricing`. + +## Channel 17 Notes + +The video channel named `video` has id `17`. + +Important settings: + +- Keep the public aliases in `channels.models`. +- Keep `channels.model_mapping` mapping each public alias to the raw upstream model. +- Keep raw upstream Seedance model names out of `abilities`. +- Keep raw upstream Seedance model metadata disabled with `models.status = 0`. +- Upstream model auto-sync for channel 17 should remain disabled, otherwise raw upstream models may reappear or public aliases may be treated as removed upstream models. + +## AistarsLab Config Sync + +Use the AistarsLab config endpoint to sync Seedance public aliases, prices, billing units, model marketplace metadata, and Channel 17 `models` / `model_mapping`. + +Preview changes: + +```bash +curl -sS 'https://token.mewinyou.shop/api/ratio_sync/aistarslab/sync' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"dry_run":true}' +``` + +Apply changes: + +```bash +curl -sS 'https://token.mewinyou.shop/api/ratio_sync/aistarslab/sync' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"dry_run":false}' +``` + +Defaults: + +```text +AISTARSLAB_CONFIG_URL=https://api.video.aistarslab.com/openapi/generation/config +AISTARSLAB_CONFIG_SYNC_CHANNEL_ID=17 +AISTARSLAB_CREDIT_RATE=100 +AISTARSLAB_MARKUP_RATE=1.3 +AISTARSLAB_CONFIG_SYNC_ENABLED=false +AISTARSLAB_CONFIG_SYNC_INTERVAL_MINUTES=30 +``` + +The sync key is read from `AISTARSLAB_API_KEY` first; if unset, it uses the configured sync channel API key. +Automatic sync is off by default and runs only on the master node when `AISTARSLAB_CONFIG_SYNC_ENABLED=true`. + +## Verification Commands + +Check that raw upstream Seedance names are not exposed: + +```bash +curl -sS 'https://token.mewinyou.shop/api/pricing' \ + | jq -r '.data[]? | select(.model_name|test("^[0-9]+:seedance-2\\\\.0")) | .model_name' +``` + +Expected output: empty. + +Check that all public aliases are visible: + +```bash +curl -sS 'https://token.mewinyou.shop/api/pricing' \ + | jq -r '.data[]? | select(.model_name|test("^seedance-.*-c[0-9]+$")) | [.model_name, .quota_type, .model_price] | @tsv' \ + | sort +``` + +Expected count: 17. + +`quota_type` meanings in this deployment: + +```text +0 = token/ratio based +1 = per item +2 = per second, display-only marketplace extension +``` + +Check user-visible model list: + +```bash +curl -sS 'https://token.mewinyou.shop/v1/models' \ + --header 'Authorization: Bearer ' \ + | jq -r '.data[]?.id' \ + | grep -E 'seedance|^[0-9]+:seedance' \ + | sort +``` + +Expected: public `seedance-...-cXX` aliases only. diff --git a/docs/seedance-video-integration.zh-CN.md b/docs/seedance-video-integration.zh-CN.md new file mode 100644 index 00000000000..20c66ef64db --- /dev/null +++ b/docs/seedance-video-integration.zh-CN.md @@ -0,0 +1,220 @@ +# Seedance 视频模型接入说明 + +本文档说明当前 NewAPI 实例中 Seedance 视频模型的对外别名、上游映射、计费规则和维护注意事项。 + +本文档面向后续 Codex 维护和人工运维交接。 + +## 对外接口 + +创建视频任务: + +```bash +curl --location --request POST 'https://token.mewinyou.shop/v1/video/generations' \ + --header 'Authorization: Bearer <用户 API Key>' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "model": "seedance-720p-fast-c37", + "prompt": "海边日落,镜头缓慢向前推进,电影感,柔和光线", + "duration": 4, + "resolution": "720p", + "size": "16:9", + "mode_type": "text2video", + "n": 1 + }' +``` + +查询任务状态: + +```bash +curl --location 'https://token.mewinyou.shop/v1/video/generations/' \ + --header 'Authorization: Bearer <用户 API Key>' +``` + +用户只能请求下面列出的对外模型别名。不要让用户直接请求 `12:seedance-2.0-720p` 这种原始上游模型名。 + +## 对外模型列表 + +| 对外模型 | 上游映射模型 | 计费单位 | 当前售价 | +| --- | --- | --- | ---: | +| `seedance-720p-fast-c37` | `37:seedance-2.0-720p-fast` | 按条 | 3.90 | +| `seedance-720p-c37` | `37:seedance-2.0-720p` | 按条 | 5.20 | +| `seedance-480p-fast-c13` | `13:seedance-2.0-480p-fast` | 按秒 | 0.39 | +| `seedance-480p-c13` | `13:seedance-2.0-480p` | 按秒 | 0.47 | +| `seedance-480p-fast-c36` | `36:seedance-2.0-480p-fast` | 按秒 | 0.39 | +| `seedance-720p-fast-c12` | `12:seedance-2.0-720p-fast` | 按秒 | 0.58 | +| `seedance-720p-c12` | `12:seedance-2.0-720p` | 按秒 | 0.68 | +| `seedance-720p-c33` | `33:seedance-2.0-720p` | 按秒 | 0.68 | +| `seedance-720p-c29` | `29:seedance-2.0-720p` | 按秒 | 0.68 | +| `seedance-1080p-c30` | `30:seedance-2.0-1080p` | 按秒 | 1.04 | +| `seedance-720p-c31` | `31:seedance-2.0-720p` | 按条 | 9.75 | +| `seedance-720p-fast-c8` | `8:seedance-2.0-720p-fast` | 按条 | 8.19 | +| `seedance-720p-c8` | `8:seedance-2.0-720p` | 按条 | 9.75 | +| `seedance-720p-fast-c35` | `35:seedance-2.0-720p-fast` | 按条 | 8.19 | +| `seedance-720p-fast-4img-c18` | `18:seedance-2.0-720p-fast-4img` | 按条 | 6.24 | +| `seedance-720p-4img-c18` | `18:seedance-2.0-720p-4img` | 按条 | 7.80 | +| `seedance-720p-c17` | `17:seedance-2.0-720p` | 按秒 | 0.68 | + +以上价格是当前配置给用户看的售价,包含 30% 加价。写入 `ModelPrice` 时不要再除以汇率。 + +## 计费规则 + +所有 Seedance 对外别名都使用 NewAPI 的 `ModelPrice`。 + +按秒模型不放进 `TASK_PRICE_PATCH`,最终计费为: + +```text +ModelPrice * duration * group_ratio +``` + +按条模型必须放进 `TASK_PRICE_PATCH`,最终计费为: + +```text +ModelPrice * group_ratio +``` + +当前 `TASK_PRICE_PATCH` 只应该包含按条别名: + +```text +seedance-720p-fast-c37, +seedance-720p-c37, +seedance-720p-c31, +seedance-720p-fast-c8, +seedance-720p-c8, +seedance-720p-fast-c35, +seedance-720p-fast-4img-c18, +seedance-720p-4img-c18 +``` + +不要把按秒模型加入 `TASK_PRICE_PATCH`。 + +## 模型广场展示 + +模型广场应把这 17 个对外模型都展示在供应商 `即梦` 下。 + +展示元数据: + +- 供应商:`即梦` +- 图标:`Jimeng.Color` +- 对外别名模型设置 `sync_official = 0` +- 按条模型标签包含 `按条` +- 按秒模型标签包含 `按秒` + +本部署中有一个本地展示补丁:模型广场使用 `quota_type = 2` 表示固定价格的按秒模型。这个字段只影响模型广场展示,不驱动真实运行时计费。 + +原始上游 Seedance 模型必须隐藏: + +```text +12:seedance-2.0-720p +12:seedance-2.0-720p-fast +13:seedance-2.0-480p +13:seedance-2.0-480p-fast +17:seedance-2.0-720p +18:seedance-2.0-720p-4img +18:seedance-2.0-720p-fast-4img +19:seedance-2.0-720p +19:seedance-2.0-720p-fast +26:seedance-2.0 +29:seedance-2.0-1080p +29:seedance-2.0-720p +30:seedance-2.0-1080p +31:seedance-2.0-720p +33:seedance-2.0-720p +33:seedance-2.0-720p-fast +35:seedance-2.0-720p-fast +36:seedance-2.0-480p-fast +37:seedance-2.0-720p +37:seedance-2.0-720p-fast +8:seedance-2.0-720p +8:seedance-2.0-720p-fast +``` + +这些原始模型不应该出现在 `/v1/models` 或 `/api/pricing`。 + +## Channel 17 维护说明 + +视频渠道名称为 `video`,渠道 ID 为 `17`。 + +关键要求: + +- `channels.models` 中保留对外别名。 +- `channels.model_mapping` 中保留对外别名到原始上游模型的映射。 +- 原始上游 Seedance 模型不要保留在 `abilities` 中。 +- 原始上游 Seedance 模型在模型广场元数据中应设置 `models.status = 0`。 +- Channel 17 的上游模型自动同步应保持关闭,否则原始上游模型可能重新出现,或者对外别名会被误判为上游已删除模型。 + +## AistarsLab 配置同步 + +可通过 AistarsLab 配置接口同步 Seedance 对外别名、价格、计费单位、模型广场元数据和 Channel 17 的 `models` / `model_mapping`。 + +手动预览变更: + +```bash +curl -sS 'https://token.mewinyou.shop/api/ratio_sync/aistarslab/sync' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"dry_run":true}' +``` + +确认后写入: + +```bash +curl -sS 'https://token.mewinyou.shop/api/ratio_sync/aistarslab/sync' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"dry_run":false}' +``` + +默认配置: + +```text +AISTARSLAB_CONFIG_URL=https://api.video.aistarslab.com/openapi/generation/config +AISTARSLAB_CONFIG_SYNC_CHANNEL_ID=17 +AISTARSLAB_CREDIT_RATE=100 +AISTARSLAB_MARKUP_RATE=1.3 +AISTARSLAB_CONFIG_SYNC_ENABLED=false +AISTARSLAB_CONFIG_SYNC_INTERVAL_MINUTES=30 +``` + +接口密钥优先从 `AISTARSLAB_API_KEY` 读取;未设置时使用同步渠道的 API Key。 +自动同步默认关闭,设置 `AISTARSLAB_CONFIG_SYNC_ENABLED=true` 后仅主节点按间隔执行。 + +## 验证命令 + +检查原始上游 Seedance 模型没有暴露: + +```bash +curl -sS 'https://token.mewinyou.shop/api/pricing' \ + | jq -r '.data[]? | select(.model_name|test("^[0-9]+:seedance-2\\\\.0")) | .model_name' +``` + +期望输出:空。 + +检查 17 个对外别名都能看到: + +```bash +curl -sS 'https://token.mewinyou.shop/api/pricing' \ + | jq -r '.data[]? | select(.model_name|test("^seedance-.*-c[0-9]+$")) | [.model_name, .quota_type, .model_price] | @tsv' \ + | sort +``` + +期望数量:17。 + +本部署中的 `quota_type` 含义: + +```text +0 = token 或倍率计费 +1 = 按条计费 +2 = 按秒计费,仅用于模型广场展示 +``` + +检查用户可见模型列表: + +```bash +curl -sS 'https://token.mewinyou.shop/v1/models' \ + --header 'Authorization: Bearer <用户 API Key>' \ + | jq -r '.data[]?.id' \ + | grep -E 'seedance|^[0-9]+:seedance' \ + | sort +``` + +期望结果:只出现 `seedance-...-cXX` 对外别名,不出现原始上游模型名。 diff --git a/dto/openai_image.go b/dto/openai_image.go index 52986fbfd59..03883ba027f 100644 --- a/dto/openai_image.go +++ b/dto/openai_image.go @@ -2,6 +2,7 @@ package dto import ( "encoding/json" + "math" "reflect" "strings" @@ -124,9 +125,152 @@ func indexComma(s string) int { return -1 } +func normalizeImageQuality(quality string) string { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "low", "medium", "high": + return strings.ToLower(strings.TrimSpace(quality)) + default: + return "medium" + } +} + +func parseImageSize(size string) (int, int, bool) { + size = strings.ToLower(strings.TrimSpace(size)) + if size == "" || size == "auto" { + size = "1024x1024" + } + parts := strings.Split(strings.ToLower(strings.TrimSpace(size)), "x") + if len(parts) != 2 { + return 0, 0, false + } + width := common.String2Int(strings.TrimSpace(parts[0])) + height := common.String2Int(strings.TrimSpace(parts[1])) + if width <= 0 || height <= 0 { + return 0, 0, false + } + return width, height, true +} + +func imageSizeTier(size string) (string, bool) { + width, height, ok := parseImageSize(size) + if !ok { + return "", false + } + longEdge := width + if height > longEdge { + longEdge = height + } + switch { + case longEdge <= 1024: + return "1k", true + case longEdge <= 2048: + return "2k", true + case longEdge <= 4096: + return "4k", true + default: + return "", false + } +} + +func imageGroupUnitPrice(size string) (float64, bool) { + tier, ok := imageSizeTier(size) + if !ok { + return 0, false + } + switch tier { + case "1k": + return 0.10, true + case "2k": + return 0.14, true + case "4k": + return 0.20, true + } + return 0, false +} + +func gptImage2UnitPrice(size string, quality string) (float64, bool) { + width, height, ok := parseImageSize(size) + if !ok { + return 0, false + } + if width%16 != 0 || height%16 != 0 { + return 0, false + } + pixels := width * height + if pixels < 655360 || pixels > 8294400 { + return 0, false + } + longEdge := width + shortEdge := height + if height > width { + longEdge = height + shortEdge = width + } + if longEdge > 3840 || float64(longEdge)/float64(shortEdge) > 3 { + return 0, false + } + + qualityGrid := map[string]int{ + "low": 16, + "medium": 48, + "high": 96, + }[normalizeImageQuality(quality)] + shortGrid := int(math.Round(float64(qualityGrid) * float64(shortEdge) / float64(longEdge))) + widthGrid := shortGrid + heightGrid := qualityGrid + if width >= height { + widthGrid = qualityGrid + heightGrid = shortGrid + } + outputTokens := math.Ceil(float64(widthGrid*heightGrid) * float64(2000000+pixels) / 4000000) + return outputTokens * 30 / 1000000, true +} + +func builtInImageUnitPrice(model string, size string, quality string) (float64, bool) { + model = strings.ToLower(strings.TrimSpace(model)) + if model == "gpt-image-2" { + return gptImage2UnitPrice(size, quality) + } + + tier, ok := imageSizeTier(size) + if !ok { + return 0, false + } + + switch model { + case "gemini-3.1-flash-image", "nano-banana-2": + switch tier { + case "1k": + return 0.067, true + case "2k": + return 0.101, true + case "4k": + return 0.151, true + } + case "gemini-3-pro-image", "nano-banana-pro": + switch tier { + case "1k", "2k": + return 0.134, true + case "4k": + return 0.240, true + } + case "gemini-2.5-flash-image", "nano-banana": + if tier == "1k" { + return 0.039, true + } + case "gemini-3.1-flash-lite-image": + if tier == "1k" { + return 0.0336, true + } + } + return 0, false +} + func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta { var sizeRatio = 1.0 var qualityRatio = 1.0 + imageUnitPrice, _ := builtInImageUnitPrice(i.Model, i.Size, i.Quality) + imageGroupUnitPrice, _ := imageGroupUnitPrice(i.Size) if strings.HasPrefix(i.Model, "dall-e") { // Size @@ -153,9 +297,11 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta { // Including n here caused double-counting for channels that also // set OtherRatio("n") (e.g. Ali/Bailian). return &types.TokenCountMeta{ - CombineText: i.Prompt, - MaxTokens: 1584, - ImagePriceRatio: sizeRatio * qualityRatio, + CombineText: i.Prompt, + MaxTokens: 1584, + ImagePriceRatio: sizeRatio * qualityRatio, + ImageUnitPrice: imageUnitPrice, + ImageGroupUnitPrice: imageGroupUnitPrice, } } diff --git a/dto/openai_image_test.go b/dto/openai_image_test.go new file mode 100644 index 00000000000..85c3f908e9b --- /dev/null +++ b/dto/openai_image_test.go @@ -0,0 +1,101 @@ +package dto + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestImageRequestBuiltInUnitPrice(t *testing.T) { + tests := []struct { + name string + request ImageRequest + want float64 + }{ + { + name: "gpt-image-2 medium 2k square", + request: ImageRequest{ + Model: "gpt-image-2", + Size: "2048x2048", + Quality: "medium", + }, + want: 0.10704, + }, + { + name: "gpt-image-2 high 4k landscape", + request: ImageRequest{ + Model: "gpt-image-2", + Size: "3840x2160", + Quality: "high", + }, + want: 0.40026, + }, + { + name: "banana 2 4k", + request: ImageRequest{ + Model: "gemini-3.1-flash-image", + Size: "4096x4096", + }, + want: 0.151, + }, + { + name: "banana pro 2k", + request: ImageRequest{ + Model: "gemini-3-pro-image", + Size: "2048x2048", + }, + want: 0.134, + }, + { + name: "empty size defaults to 1k", + request: ImageRequest{ + Model: "gemini-3.1-flash-image", + }, + want: 0.067, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + meta := test.request.GetTokenCountMeta() + require.InDelta(t, test.want, meta.ImageUnitPrice, 0.000001) + }) + } +} + +func TestImageRequestImageGroupUnitPrice(t *testing.T) { + tests := []struct { + name string + size string + want float64 + }{ + {name: "empty defaults to 1k", want: 0.10}, + {name: "1k", size: "1024x1024", want: 0.10}, + {name: "2k", size: "2048x2048", want: 0.14}, + {name: "4k", size: "4096x4096", want: 0.20}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := ImageRequest{ + Model: "any-image-model", + Size: test.size, + } + meta := req.GetTokenCountMeta() + require.InDelta(t, test.want, meta.ImageGroupUnitPrice, 0.000001) + }) + } +} + +func TestImageRequestUnknownBuiltInPriceKeepsLegacyImageRatio(t *testing.T) { + req := ImageRequest{ + Model: "dall-e-3", + Size: "1024x1792", + Quality: "hd", + } + + meta := req.GetTokenCountMeta() + + require.Zero(t, meta.ImageUnitPrice) + require.Equal(t, 3.0, meta.ImagePriceRatio) +} diff --git a/main.go b/main.go index dbbf44a1826..f162122863c 100644 --- a/main.go +++ b/main.go @@ -112,6 +112,12 @@ func main() { // Subscription quota reset task (daily/weekly/monthly/custom) service.StartSubscriptionQuotaResetTask() + // Local image generation result retention cleanup. + service.StartImageGenerationCleanupTask() + + // Optional AistarsLab video model/price sync task. + service.StartAistarsLabConfigSyncTask() + // Wire task polling adaptor factory (breaks service -> relay import cycle) service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor { a := relay.GetTaskAdaptor(platform) diff --git a/model/image_generation.go b/model/image_generation.go new file mode 100644 index 00000000000..62d2d747a26 --- /dev/null +++ b/model/image_generation.go @@ -0,0 +1,241 @@ +package model + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +const ( + ImageGenerationStatusSuccess = "SUCCESS" + ImageGenerationStatusExpired = "EXPIRED" +) + +type ImageGeneration struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + TokenId int `json:"token_id" gorm:"index"` + ChannelId int `json:"channel_id" gorm:"index"` + RequestId string `json:"request_id" gorm:"type:varchar(64);index"` + ImageIndex int `json:"image_index" gorm:"index"` + ModelName string `json:"model_name" gorm:"index"` + Prompt string `json:"prompt" gorm:"type:text"` + Size string `json:"size" gorm:"type:varchar(64)"` + Quality string `json:"quality" gorm:"type:varchar(64)"` + Quota int `json:"quota"` + FilePath string `json:"file_path" gorm:"type:text"` + MimeType string `json:"mime_type" gorm:"type:varchar(64)"` + Status string `json:"status" gorm:"type:varchar(20);index"` + Group string `json:"group" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UseTime int64 `json:"use_time" gorm:"bigint"` + ExpireAt int64 `json:"expire_at" gorm:"bigint;index"` +} + +func InsertImageGeneration(record *ImageGeneration) error { + return DB.Create(record).Error +} + +func GetImageGenerationByID(id int) (*ImageGeneration, error) { + var record ImageGeneration + err := DB.Where("id = ?", id).First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func GetExpiredImageGenerations(now int64, limit int) ([]*ImageGeneration, error) { + var records []*ImageGeneration + err := DB.Where("status = ? AND expire_at <= ?", ImageGenerationStatusSuccess, now). + Limit(limit). + Find(&records).Error + return records, err +} + +func MarkImageGenerationExpired(id int) error { + return DB.Model(&ImageGeneration{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "status": ImageGenerationStatusExpired, + "file_path": "", + }).Error +} + +func imageGenerationQuery(queryParams TaskQueryParams, userId *int) *gorm.DB { + tx := DB.Model(&ImageGeneration{}) + if userId != nil { + tx = tx.Where("user_id = ?", *userId) + } + if queryParams.ChannelID != "" { + tx = tx.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.MjID != "" { + tx = tx.Where("request_id = ?", queryParams.MjID) + } + if startTimestamp := taskTimestampMillisToSeconds(queryParams.StartTimestamp); startTimestamp > 0 { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if endTimestamp := taskTimestampMillisToSeconds(queryParams.EndTimestamp); endTimestamp > 0 { + tx = tx.Where("created_at <= ?", endTimestamp) + } + return tx +} + +func GetAllImageGenerationTasks(startIdx int, num int, queryParams TaskQueryParams, userId *int) []*Midjourney { + var records []*ImageGeneration + err := imageGenerationQuery(queryParams, userId). + Order("created_at desc, id desc"). + Limit(num). + Offset(startIdx). + Find(&records).Error + if err != nil { + return nil + } + + items := make([]*Midjourney, 0, len(records)) + for _, record := range records { + items = append(items, imageGenerationToMidjourney(record)) + } + return items +} + +func CountAllImageGenerationTasks(queryParams TaskQueryParams, userId *int) int64 { + var total int64 + _ = imageGenerationQuery(queryParams, userId).Count(&total).Error + return total +} + +func imageGenerationToMidjourney(record *ImageGeneration) *Midjourney { + imageURL := "" + failReason := "" + status := record.Status + if status == "" { + status = ImageGenerationStatusSuccess + } + if status == ImageGenerationStatusSuccess && record.FilePath != "" { + imageURL = imageGenerationContentURL(record) + } + if status == ImageGenerationStatusExpired { + failReason = "图片已过期" + } + + mjID := record.RequestId + if record.ImageIndex > 0 { + mjID = mjID + "#" + strconv.Itoa(record.ImageIndex+1) + } + useTime := imageGenerationUseTimeSeconds(record) + submitTime := record.CreatedAt * 1000 + if useTime > 0 { + submitTime = (record.CreatedAt - useTime) * 1000 + } + + return &Midjourney{ + Id: -record.Id, + Code: 1, + UserId: record.UserId, + Action: "IMAGE_GENERATION", + MjId: mjID, + Prompt: imageGenerationPrompt(record), + PromptEn: record.ModelName, + SubmitTime: submitTime, + StartTime: submitTime, + FinishTime: record.CreatedAt * 1000, + ImageUrl: imageURL, + Status: status, + Progress: "100%", + FailReason: failReason, + ChannelId: record.ChannelId, + Quota: record.Quota, + } +} + +func imageGenerationPrompt(record *ImageGeneration) string { + parts := make([]string, 0, 4) + if record.Size != "" { + parts = append(parts, "大小 "+record.Size) + } + quality := record.Quality + if quality == "" { + quality = "standard" + } + if quality != "" { + parts = append(parts, "品质 "+quality) + } + parts = append(parts, "生成数量 1") + if record.Prompt != "" { + parts = append(parts, "提示词 "+record.Prompt) + } + return strings.Join(parts, ", ") +} + +func imageGenerationUseTimeSeconds(record *ImageGeneration) int64 { + if record.UseTime > 0 { + return record.UseTime + } + if record.RequestId == "" { + return 0 + } + var log Log + result := LOG_DB.Model(&Log{}). + Select("use_time"). + Where("request_id = ? AND type = ? AND use_time > 0", record.RequestId, LogTypeConsume). + Order("id desc"). + Limit(1). + Find(&log) + if result.Error != nil || result.RowsAffected == 0 { + return 0 + } + if log.UseTime < 0 { + return 0 + } + return int64(log.UseTime) +} + +func imageGenerationContentURL(record *ImageGeneration) string { + if record == nil { + return "" + } + expires := record.ExpireAt + if expires <= 0 { + expires = record.CreatedAt + 7*24*60*60 + } + return fmt.Sprintf( + "/api/image-generations/%d/content?expires=%d&signature=%s", + record.Id, + expires, + GenerateImageGenerationContentSignature(record, expires), + ) +} + +func GenerateImageGenerationContentSignature(record *ImageGeneration, expires int64) string { + if record == nil { + return "" + } + payload := fmt.Sprintf( + "image-generation-content:%d:%d:%d:%s:%s:%d", + record.Id, + record.UserId, + expires, + record.FilePath, + record.Status, + record.ExpireAt, + ) + return common.GenerateHMAC(payload) +} + +func ValidateImageGenerationContentSignature(record *ImageGeneration, expires int64, signature string) bool { + if record == nil || signature == "" || expires <= time.Now().Unix() { + return false + } + if record.ExpireAt > 0 && expires > record.ExpireAt { + return false + } + expected := GenerateImageGenerationContentSignature(record, expires) + return expected != "" && expected == signature +} diff --git a/model/main.go b/model/main.go index f37cb667cd4..29188693099 100644 --- a/model/main.go +++ b/model/main.go @@ -265,6 +265,7 @@ func migrateDB() error { &Ability{}, &Log{}, &Midjourney{}, + &ImageGeneration{}, &TopUp{}, &QuotaData{}, &Task{}, @@ -313,6 +314,7 @@ func migrateDBFast() error { {&Ability{}, "Ability"}, {&Log{}, "Log"}, {&Midjourney{}, "Midjourney"}, + {&ImageGeneration{}, "ImageGeneration"}, {&TopUp{}, "TopUp"}, {&QuotaData{}, "QuotaData"}, {&Task{}, "Task"}, diff --git a/model/midjourney.go b/model/midjourney.go index e1a8d772b06..ed63150646a 100644 --- a/model/midjourney.go +++ b/model/midjourney.go @@ -1,5 +1,10 @@ package model +import ( + "sort" + "strconv" +) + type Midjourney struct { Id int `json:"id"` Code int `json:"code"` @@ -218,3 +223,61 @@ func CountAllUserTask(userId int, queryParams TaskQueryParams) int64 { _ = query.Count(&total).Error return total } + +func GetAllDrawingLogs(startIdx int, num int, queryParams TaskQueryParams) []*Midjourney { + limit := startIdx + num + items := append( + GetAllTasks(0, limit, queryParams), + GetAllImageGenerationTasks(0, limit, queryParams, nil)..., + ) + return paginateDrawingLogs(items, startIdx, num) +} + +func GetAllUserDrawingLogs(userId int, startIdx int, num int, queryParams TaskQueryParams) []*Midjourney { + limit := startIdx + num + items := append( + GetAllUserTask(userId, 0, limit, queryParams), + GetAllImageGenerationTasks(0, limit, queryParams, &userId)..., + ) + return paginateDrawingLogs(items, startIdx, num) +} + +func CountAllDrawingLogs(queryParams TaskQueryParams) int64 { + return CountAllTasks(queryParams) + CountAllImageGenerationTasks(queryParams, nil) +} + +func CountAllUserDrawingLogs(userId int, queryParams TaskQueryParams) int64 { + return CountAllUserTask(userId, queryParams) + CountAllImageGenerationTasks(queryParams, &userId) +} + +func paginateDrawingLogs(items []*Midjourney, startIdx int, num int) []*Midjourney { + sort.SliceStable(items, func(i, j int) bool { + if items[i].SubmitTime == items[j].SubmitTime { + return items[i].Id > items[j].Id + } + return items[i].SubmitTime > items[j].SubmitTime + }) + + if startIdx >= len(items) { + return []*Midjourney{} + } + endIdx := startIdx + num + if endIdx > len(items) { + endIdx = len(items) + } + return items[startIdx:endIdx] +} + +func taskTimestampMillisToSeconds(raw string) int64 { + if raw == "" { + return 0 + } + timestamp, err := strconv.ParseInt(raw, 10, 64) + if err != nil || timestamp <= 0 { + return 0 + } + if timestamp > 1_000_000_000_000 { + return timestamp / 1000 + } + return timestamp +} diff --git a/model/midjourney_test.go b/model/midjourney_test.go new file mode 100644 index 00000000000..76345dfedb3 --- /dev/null +++ b/model/midjourney_test.go @@ -0,0 +1,120 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + + "github.com/stretchr/testify/require" +) + +func TestGetAllUserDrawingLogsIncludesImageGenerationLogs(t *testing.T) { + truncateTables(t) + + require.NoError(t, DB.Create(&Midjourney{ + Id: 1, + UserId: 1, + Action: "IMAGINE", + MjId: "mj_old", + Prompt: "old mj prompt", + SubmitTime: 1000, + Status: "SUCCESS", + Progress: "100%", + ChannelId: 9, + }).Error) + + require.NoError(t, DB.Create(&ImageGeneration{ + Id: 10, + UserId: 1, + CreatedAt: 100, + UseTime: 3, + ExpireAt: 4_102_444_800, + Status: ImageGenerationStatusSuccess, + Prompt: "a red cube", + Size: "1024x1024", + Quality: "standard", + ModelName: "gemini-3.1-flash-image", + Quota: 50000, + ChannelId: 23, + RequestId: "req_image", + FilePath: "20260710/user-1/req_image-0.png", + }).Error) + + require.NoError(t, LOG_DB.Create(&Log{ + Id: 11, + UserId: 1, + CreatedAt: 3, + Type: LogTypeConsume, + Content: "chat", + ModelName: "gpt-4o", + Other: common.MapToJsonStr(map[string]interface{}{ + "request_path": "/v1/chat/completions", + }), + }).Error) + + items := GetAllUserDrawingLogs(1, 0, 10, TaskQueryParams{}) + require.Len(t, items, 2) + require.Equal(t, "req_image", items[0].MjId) + require.Equal(t, "IMAGE_GENERATION", items[0].Action) + require.Equal(t, "SUCCESS", items[0].Status) + require.Equal(t, "100%", items[0].Progress) + require.Equal(t, int64(97000), items[0].SubmitTime) + require.Equal(t, int64(100000), items[0].FinishTime) + require.Contains(t, items[0].ImageUrl, "/api/image-generations/10/content?expires=") + require.Contains(t, items[0].ImageUrl, "signature=") + require.Contains(t, items[0].Prompt, "大小 1024x1024") + require.Contains(t, items[0].Prompt, "品质 standard") + require.Contains(t, items[0].Prompt, "提示词 a red cube") + require.Equal(t, "gemini-3.1-flash-image", items[0].PromptEn) + require.Equal(t, 50000, items[0].Quota) + require.Equal(t, "mj_old", items[1].MjId) + require.Equal(t, int64(2), CountAllUserDrawingLogs(1, TaskQueryParams{})) +} + +func TestGetAllUserDrawingLogsFiltersImageGenerationByRequestID(t *testing.T) { + truncateTables(t) + + require.NoError(t, DB.Create(&ImageGeneration{ + Id: 20, + UserId: 1, + CreatedAt: 2, + Status: ImageGenerationStatusSuccess, + Prompt: "match", + ModelName: "gpt-image-2", + RequestId: "req_match", + }).Error) + require.NoError(t, DB.Create(&ImageGeneration{ + Id: 21, + UserId: 1, + CreatedAt: 3, + Status: ImageGenerationStatusSuccess, + Prompt: "other", + ModelName: "gpt-image-2", + RequestId: "req_other", + }).Error) + + items := GetAllUserDrawingLogs(1, 0, 10, TaskQueryParams{MjID: "req_match"}) + require.Len(t, items, 1) + require.Equal(t, "req_match", items[0].MjId) +} + +func TestGetAllUserDrawingLogsShowsExpiredImageGeneration(t *testing.T) { + truncateTables(t) + + require.NoError(t, DB.Create(&ImageGeneration{ + Id: 30, + UserId: 1, + CreatedAt: 2, + Status: ImageGenerationStatusExpired, + Prompt: "expired", + ModelName: "gpt-image-2", + RequestId: "req_expired", + FilePath: "", + }).Error) + + items := GetAllUserDrawingLogs(1, 0, 10, TaskQueryParams{}) + require.Len(t, items, 1) + require.Equal(t, "EXPIRED", items[0].Status) + require.Equal(t, "", items[0].ImageUrl) + require.Equal(t, "图片已过期", items[0].FailReason) +} diff --git a/model/option.go b/model/option.go index 37fb6cf5bdc..4add814d7c1 100644 --- a/model/option.go +++ b/model/option.go @@ -143,6 +143,7 @@ func InitOptionMap() { common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString() common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString() common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString() + common.OptionMap["TaskBillingUnit"] = ratio_setting.EffectiveTaskBillingUnit2JSONString() common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString() common.OptionMap["CreateCacheRatio"] = ratio_setting.CreateCacheRatio2JSONString() common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() @@ -511,6 +512,14 @@ func updateOptionMap(key string, value string) (err error) { err = ratio_setting.UpdateCompletionRatioByJSONString(value) case "ModelPrice": err = ratio_setting.UpdateModelPriceByJSONString(value) + if err == nil { + common.OptionMap["TaskBillingUnit"] = ratio_setting.EffectiveTaskBillingUnit2JSONString() + } + case "TaskBillingUnit": + err = ratio_setting.UpdateTaskBillingUnitByJSONString(value) + if err == nil { + common.OptionMap["TaskBillingUnit"] = ratio_setting.EffectiveTaskBillingUnit2JSONString() + } case "CacheRatio": err = ratio_setting.UpdateCacheRatioByJSONString(value) case "CreateCacheRatio": diff --git a/model/pricing.go b/model/pricing.go index 54ae9845133..84416aa5550 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -295,7 +295,7 @@ func updatePricing() { modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) if findPrice { pricing.ModelPrice = modelPrice - pricing.QuotaType = 1 + pricing.QuotaType = fixedPriceQuotaType(model, pricing.Tags) } else { modelRatio, _, _ := ratio_setting.GetModelRatio(model) pricing.ModelRatio = modelRatio @@ -340,6 +340,19 @@ func updatePricing() { lastGetPricingTime = time.Now() } +func fixedPriceQuotaType(modelName, tags string) int { + if ratio_setting.IsTaskPerSecondBilling(modelName) { + return 2 + } + if ratio_setting.IsTaskPerItemBilling(modelName) { + return 1 + } + if strings.Contains(tags, "按秒") { + return 2 + } + return 1 +} + // GetSupportedEndpointMap 返回全局端点到路径的映射 func GetSupportedEndpointMap() map[string]common.EndpointInfo { return supportedEndpointMap diff --git a/model/pricing_test.go b/model/pricing_test.go new file mode 100644 index 00000000000..5ecae025873 --- /dev/null +++ b/model/pricing_test.go @@ -0,0 +1,51 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/stretchr/testify/assert" +) + +func TestFixedPriceQuotaTypeSeedanceUsesTaskPricePatch(t *testing.T) { + original := constant.TaskPricePatches + t.Cleanup(func() { + constant.TaskPricePatches = original + }) + + constant.TaskPricePatches = []string{"seedance-720p-c37"} + requireNoError(t, ratio_setting.UpdateTaskBillingUnitByJSONString("{}")) + + assert.Equal(t, 1, fixedPriceQuotaType("seedance-720p-c37", "video,seedance,??")) + assert.Equal(t, 2, fixedPriceQuotaType("seedance-480p-fast-c13", "video,seedance,??")) +} + +func TestFixedPriceQuotaTypeTaskBillingUnitOverridesPatch(t *testing.T) { + original := constant.TaskPricePatches + t.Cleanup(func() { + constant.TaskPricePatches = original + requireNoError(t, ratio_setting.UpdateTaskBillingUnitByJSONString("{}")) + }) + + constant.TaskPricePatches = []string{"seedance-480p-fast-c13"} + requireNoError(t, ratio_setting.UpdateTaskBillingUnitByJSONString(`{ + "seedance-480p-fast-c13": "per_second", + "seedance-720p-c37": "per_item" + }`)) + + assert.Equal(t, 2, fixedPriceQuotaType("seedance-480p-fast-c13", "video")) + assert.Equal(t, 1, fixedPriceQuotaType("seedance-720p-c37", "video,按秒")) +} + +func TestFixedPriceQuotaTypeTagsStillSupportPerSecond(t *testing.T) { + assert.Equal(t, 2, fixedPriceQuotaType("custom-video-model", "video,按秒")) + assert.Equal(t, 1, fixedPriceQuotaType("custom-video-model", "video")) +} + +func requireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} diff --git a/model/task_cas_test.go b/model/task_cas_test.go index ba34a73291b..708993f4147 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -35,6 +35,8 @@ func TestMain(m *testing.M) { if err := db.AutoMigrate( &Task{}, + &Midjourney{}, + &ImageGeneration{}, &User{}, &Token{}, &Log{}, @@ -54,6 +56,8 @@ func truncateTables(t *testing.T) { t.Helper() t.Cleanup(func() { DB.Exec("DELETE FROM tasks") + DB.Exec("DELETE FROM midjourneys") + DB.Exec("DELETE FROM image_generations") DB.Exec("DELETE FROM users") DB.Exec("DELETE FROM tokens") DB.Exec("DELETE FROM logs") diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 680c4ee484e..b37574b1414 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -5,8 +5,10 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/openai" @@ -59,7 +61,32 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { if !strings.HasPrefix(info.UpstreamModelName, "imagen") { - return nil, errors.New("not supported model for image generation, only imagen models are supported") + if !isGeminiNativeImageGenerationModel(info.UpstreamModelName) { + return nil, errors.New("not supported model for image generation, only imagen or Gemini native image models are supported") + } + if lo.FromPtrOr(request.N, uint(1)) > 1 { + return nil, errors.New("Gemini native image generation only supports n=1") + } + imageConfig, err := buildGeminiNativeImageConfig(request) + if err != nil { + return nil, err + } + return dto.GeminiChatRequest{ + Contents: []dto.GeminiChatContent{ + { + Role: "user", + Parts: []dto.GeminiPart{ + { + Text: request.Prompt, + }, + }, + }, + }, + GenerationConfig: dto.GeminiChatGenerationConfig{ + ResponseModalities: []string{"TEXT", "IMAGE"}, + ImageConfig: imageConfig, + }, + }, nil } // convert size to aspect ratio but allow user to specify aspect ratio @@ -123,6 +150,96 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf return geminiRequest, nil } +func isGeminiNativeImageGenerationModel(model string) bool { + if strings.HasPrefix(model, "imagen") { + return false + } + if model_setting.IsGeminiModelSupportImagine(model) { + return true + } + return strings.HasPrefix(model, "gemini-") && + (strings.Contains(model, "-image") || strings.Contains(model, "image-generation")) +} + +func buildGeminiNativeImageConfig(request dto.ImageRequest) ([]byte, error) { + imageSize, aspectRatio := geminiNativeImageSizeAndAspectRatio(request.Size) + imageConfig := map[string]interface{}{ + "imageSize": imageSize, + } + if aspectRatio != "" { + imageConfig["aspectRatio"] = aspectRatio + } + imageConfigBytes, err := common.Marshal(imageConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal image config: %w", err) + } + return imageConfigBytes, nil +} + +func geminiNativeImageSizeAndAspectRatio(size string) (string, string) { + size = strings.TrimSpace(size) + if size == "" || strings.EqualFold(size, "auto") { + return "1K", "" + } + if strings.Contains(size, ":") { + return "1K", size + } + + parts := strings.Split(strings.ToLower(size), "x") + if len(parts) != 2 { + return "1K", "" + } + width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) + height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return "1K", "" + } + + imageSize := "1K" + longEdge := width + if height > longEdge { + longEdge = height + } + if longEdge > 2048 { + imageSize = "4K" + } else if longEdge > 1024 { + imageSize = "2K" + } + + return imageSize, geminiNativeAspectRatio(width, height) +} + +func geminiNativeAspectRatio(width, height int) string { + switch fmt.Sprintf("%dx%d", width, height) { + case "256x256", "512x512", "1024x1024", "2048x2048", "4096x4096": + return "1:1" + case "1536x1024": + return "3:2" + case "1024x1536": + return "2:3" + case "1024x1792": + return "9:16" + case "1792x1024": + return "16:9" + } + + divisor := greatestCommonDivisor(width, height) + return fmt.Sprintf("%d:%d", width/divisor, height/divisor) +} + +func greatestCommonDivisor(a, b int) int { + for b != 0 { + a, b = b, a%b + } + if a < 0 { + return -a + } + if a == 0 { + return 1 + } + return a +} + func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } @@ -259,8 +376,14 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom } } - if strings.HasPrefix(info.UpstreamModelName, "imagen") { - return GeminiImageHandler(c, info, resp) + if info.RelayMode == constant.RelayModeImagesGenerations || + info.RelayMode == constant.RelayModeImagesEdits { + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + return GeminiImageHandler(c, info, resp) + } + if isGeminiNativeImageGenerationModel(info.UpstreamModelName) { + return GeminiNativeImageHandler(c, info, resp) + } } // check if the model is an embedding model diff --git a/relay/channel/gemini/adaptor_image_test.go b/relay/channel/gemini/adaptor_image_test.go new file mode 100644 index 00000000000..c841c951d44 --- /dev/null +++ b/relay/channel/gemini/adaptor_image_test.go @@ -0,0 +1,124 @@ +package gemini + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestConvertImageRequestGeminiNativeImageModel(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + + n := uint(1) + converted, err := (&Adaptor{}).ConvertImageRequest(c, &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gemini-3.1-flash-image", + }, + }, dto.ImageRequest{ + Prompt: "draw a small red house", + Size: "2048x2048", + N: &n, + }) + + require.NoError(t, err) + geminiRequest, ok := converted.(dto.GeminiChatRequest) + require.True(t, ok) + require.Len(t, geminiRequest.Contents, 1) + require.Equal(t, "user", geminiRequest.Contents[0].Role) + require.Equal(t, "draw a small red house", geminiRequest.Contents[0].Parts[0].Text) + require.Equal(t, []string{"TEXT", "IMAGE"}, geminiRequest.GenerationConfig.ResponseModalities) + + var imageConfig map[string]string + require.NoError(t, common.Unmarshal(geminiRequest.GenerationConfig.ImageConfig, &imageConfig)) + require.Equal(t, "2K", imageConfig["imageSize"]) + require.Equal(t, "1:1", imageConfig["aspectRatio"]) +} + +func TestConvertImageRequestGeminiNativeImageRejectsMultipleImages(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + + n := uint(2) + _, err := (&Adaptor{}).ConvertImageRequest(c, &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gemini-3.1-flash-image", + }, + }, dto.ImageRequest{ + Prompt: "draw a small red house", + Size: "1024x1024", + N: &n, + }) + + require.ErrorContains(t, err, "only supports n=1") +} + +func TestGeminiNativeImageHandlerConvertsInlineImageToOpenAIImageResponse(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + + payload := dto.GeminiChatResponse{ + Candidates: []dto.GeminiChatCandidate{ + { + Content: dto.GeminiChatContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {Text: "revised prompt"}, + { + InlineData: &dto.GeminiInlineData{ + MimeType: "image/png", + Data: "aW1hZ2UtYnl0ZXM=", + }, + }, + }, + }, + }, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: 11, + CandidatesTokenCount: 22, + TotalTokenCount: 33, + }, + } + body, err := common.Marshal(payload) + require.NoError(t, err) + + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gemini-3.1-flash-image", + }, + } + usage, newAPIError := GeminiNativeImageHandler(c, info, &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + }) + + require.Nil(t, newAPIError) + require.NotNil(t, usage) + require.Equal(t, 11, usage.PromptTokens) + require.Equal(t, 22, usage.CompletionTokens) + require.Equal(t, 33, usage.TotalTokens) + require.Equal(t, float64(1), info.PriceData.OtherRatios["n"]) + + var openAIResponse dto.ImageResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &openAIResponse)) + require.Len(t, openAIResponse.Data, 1) + require.Equal(t, "aW1hZ2UtYnl0ZXM=", openAIResponse.Data[0].B64Json) + require.Equal(t, "revised prompt", openAIResponse.Data[0].RevisedPrompt) +} diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 69175e76efc..bf0b188f7ff 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1582,6 +1582,62 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http. return usage, nil } +func GeminiNativeImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + + var geminiResponse dto.GeminiChatResponse + if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + openAIResponse := dto.ImageResponse{ + Created: common.GetTimestamp(), + } + var revisedPrompts []string + for _, candidate := range geminiResponse.Candidates { + for _, part := range candidate.Content.Parts { + if strings.TrimSpace(part.Text) != "" { + revisedPrompts = append(revisedPrompts, strings.TrimSpace(part.Text)) + } + if part.InlineData == nil || + part.InlineData.Data == "" || + !strings.HasPrefix(strings.ToLower(part.InlineData.MimeType), "image/") { + continue + } + openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{ + B64Json: part.InlineData.Data, + }) + } + } + + if len(openAIResponse.Data) == 0 { + return nil, types.NewOpenAIError(errors.New("no images generated"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if len(revisedPrompts) > 0 { + revisedPrompt := strings.Join(revisedPrompts, "\n") + for i := range openAIResponse.Data { + openAIResponse.Data[i].RevisedPrompt = revisedPrompt + } + } + info.PriceData.AddOtherRatio("n", float64(len(openAIResponse.Data))) + + jsonResponse, jsonErr := common.Marshal(openAIResponse) + if jsonErr != nil { + return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + + usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens()) + return &usage, nil +} + type GeminiModelsResponse struct { Models []dto.GeminiModel `json:"models"` NextPageToken string `json:"nextPageToken"` diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go index e9029aa20d4..18643f9cf7a 100644 --- a/relay/channel/task/sora/adaptor.go +++ b/relay/channel/task/sora/adaptor.go @@ -39,22 +39,47 @@ type ImageURL struct { } type responseTask struct { - ID string `json:"id"` - TaskID string `json:"task_id,omitempty"` //兼容旧接口 - Object string `json:"object"` - Model string `json:"model"` - Status string `json:"status"` - Progress int `json:"progress"` - CreatedAt int64 `json:"created_at"` - CompletedAt int64 `json:"completed_at,omitempty"` - ExpiresAt int64 `json:"expires_at,omitempty"` - Seconds string `json:"seconds,omitempty"` - Size string `json:"size,omitempty"` - RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"` - Error *struct { - Message string `json:"message"` - Code string `json:"code"` - } `json:"error,omitempty"` + ID string `json:"id"` + TaskID string `json:"task_id,omitempty"` //兼容旧接口 + Object string `json:"object"` + Model string `json:"model"` + Status string `json:"status"` + Progress int `json:"progress"` + CreatedAt int64 `json:"created_at"` + CompletedAt int64 `json:"completed_at,omitempty"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` + RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"` + Error *responseTaskError `json:"error,omitempty"` +} + +type responseTaskError struct { + Message string `json:"message"` + Code string `json:"code"` +} + +func (e *responseTaskError) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" || trimmed == "null" { + return nil + } + if strings.HasPrefix(trimmed, `"`) { + var message string + if err := common.Unmarshal(data, &message); err != nil { + return err + } + e.Message = message + return nil + } + + type alias responseTaskError + var parsed alias + if err := common.Unmarshal(data, &parsed); err != nil { + return err + } + *e = responseTaskError(parsed) + return nil } // ============================ @@ -307,12 +332,16 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e // Url intentionally left empty — the caller constructs the proxy URL using the public task ID case "failed", "cancelled": taskResult.Status = model.TaskStatusFailure - if resTask.Error != nil { + if resTask.Error != nil && resTask.Error.Message != "" { taskResult.Reason = resTask.Error.Message } else { taskResult.Reason = "task failed" } default: + if resTask.Error != nil && resTask.Error.Message != "" { + taskResult.Status = model.TaskStatusFailure + taskResult.Reason = resTask.Error.Message + } } if resTask.Progress > 0 && resTask.Progress < 100 { taskResult.Progress = fmt.Sprintf("%d%%", resTask.Progress) diff --git a/relay/channel/task/sora/adaptor_test.go b/relay/channel/task/sora/adaptor_test.go new file mode 100644 index 00000000000..3199a7cf28f --- /dev/null +++ b/relay/channel/task/sora/adaptor_test.go @@ -0,0 +1,56 @@ +package sora + +import ( + "testing" + + "github.com/QuantumNous/new-api/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTaskResultFailedWithStringError(t *testing.T) { + adaptor := &TaskAdaptor{} + + taskInfo, err := adaptor.ParseTaskResult([]byte(`{ + "id": "task_upstream", + "status": "failed", + "error": "safety system rejected this request" + }`)) + + require.NoError(t, err) + require.NotNil(t, taskInfo) + assert.Equal(t, model.TaskStatusFailure, taskInfo.Status) + assert.Equal(t, "safety system rejected this request", taskInfo.Reason) +} + +func TestParseTaskResultFailedWithObjectError(t *testing.T) { + adaptor := &TaskAdaptor{} + + taskInfo, err := adaptor.ParseTaskResult([]byte(`{ + "id": "task_upstream", + "status": "failed", + "error": {"message": "invalid prompt", "code": "invalid_request"} + }`)) + + require.NoError(t, err) + require.NotNil(t, taskInfo) + assert.Equal(t, model.TaskStatusFailure, taskInfo.Status) + assert.Equal(t, "invalid prompt", taskInfo.Reason) +} + +func TestParseTaskResultErrorWithoutStatus(t *testing.T) { + adaptor := &TaskAdaptor{} + + taskInfo, err := adaptor.ParseTaskResult([]byte(`{ + "code": "Client specified an invalid argument", + "error": "Generated video rejected by content moderation.", + "id": "task_upstream", + "task_id": "task_upstream", + "model": "grok-image-video" + }`)) + + require.NoError(t, err) + require.NotNil(t, taskInfo) + assert.Equal(t, model.TaskStatusFailure, taskInfo.Status) + assert.Equal(t, "Generated video rejected by content moderation.", taskInfo.Reason) +} diff --git a/relay/helper/price.go b/relay/helper/price.go index 8ba0ee8f084..eb1c50ad434 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -2,6 +2,7 @@ package helper import ( "fmt" + "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" @@ -14,6 +15,10 @@ import ( "github.com/gin-gonic/gin" ) +func isImagePricingGroup(group string) bool { + return strings.EqualFold(strings.TrimSpace(group), "image") +} + func modelPriceNotConfiguredError(modelName string, userId int) error { if model.IsAdmin(userId) { return fmt.Errorf( @@ -62,10 +67,24 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types. } func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (types.PriceData, error) { + if meta == nil { + meta = &types.TokenCountMeta{} + } modelPrice, usePrice := ratio_setting.GetModelPrice(info.OriginModelName, false) groupRatioInfo := HandleGroupRatio(c, info) + imageUnitPriceOverride := false + if isImagePricingGroup(info.UsingGroup) && meta.ImageGroupUnitPrice > 0 { + modelPrice = meta.ImageGroupUnitPrice + usePrice = true + imageUnitPriceOverride = true + } else if meta.ImageUnitPrice > 0 { + modelPrice = meta.ImageUnitPrice + usePrice = true + imageUnitPriceOverride = true + } + var preConsumedQuota int var modelRatio float64 var completionRatio float64 @@ -106,7 +125,10 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens ratio := modelRatio * groupRatioInfo.GroupRatio preConsumedQuota = int(float64(preConsumedTokens) * ratio) } else { - if meta.ImagePriceRatio != 0 { + if imageUnitPriceOverride { + // Built-in image prices already represent the final single-image price + // for the requested model/size/quality. + } else if meta.ImagePriceRatio != 0 { modelPrice = modelPrice * meta.ImagePriceRatio } preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go new file mode 100644 index 00000000000..0a5d69f13be --- /dev/null +++ b/relay/helper/price_test.go @@ -0,0 +1,91 @@ +package helper + +import ( + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestModelPriceHelperUsesBuiltInImageUnitPrice(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + info := &relaycommon.RelayInfo{ + OriginModelName: "gemini-3.1-flash-image", + UsingGroup: "default", + } + + priceData, err := ModelPriceHelper(ctx, info, 1, &types.TokenCountMeta{ + ImageUnitPrice: 0.101, + }) + + require.NoError(t, err) + require.True(t, priceData.UsePrice) + require.Equal(t, 0.101, priceData.ModelPrice) + require.Equal(t, int(0.101*common.QuotaPerUnit), priceData.QuotaToPreConsume) +} + +func TestModelPriceHelperBuiltInImageUnitPriceSkipsImageRatio(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + info := &relaycommon.RelayInfo{ + OriginModelName: "gpt-image-2", + UsingGroup: "default", + } + + priceData, err := ModelPriceHelper(ctx, info, 1, &types.TokenCountMeta{ + ImageUnitPrice: 0.10704, + ImagePriceRatio: 16, + }) + + require.NoError(t, err) + require.True(t, priceData.UsePrice) + require.Equal(t, 0.10704, priceData.ModelPrice) + require.Equal(t, int(0.10704*common.QuotaPerUnit), priceData.QuotaToPreConsume) +} + +func TestModelPriceHelperImageGroupUsesResolutionUnitPriceForAnyModel(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + info := &relaycommon.RelayInfo{ + OriginModelName: "unknown-image-model", + UsingGroup: "image", + } + + priceData, err := ModelPriceHelper(ctx, info, 1, &types.TokenCountMeta{ + ImageGroupUnitPrice: 0.14, + }) + + require.NoError(t, err) + require.True(t, priceData.UsePrice) + require.Equal(t, 0.14, priceData.ModelPrice) + require.Equal(t, int(0.14*common.QuotaPerUnit), priceData.QuotaToPreConsume) +} + +func TestModelPriceHelperNonImageGroupIgnoresResolutionUnitPrice(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + info := &relaycommon.RelayInfo{ + OriginModelName: "unknown-image-model", + UsingGroup: "default", + } + + _, err := ModelPriceHelper(ctx, info, 1, &types.TokenCountMeta{ + ImageGroupUnitPrice: 0.14, + }) + + require.Error(t, err) +} diff --git a/relay/image_handler.go b/relay/image_handler.go index a4fee7d9e0a..046a10e4833 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -106,7 +106,11 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type } } + originalWriter := c.Writer + responseCapture := &imageResponseCaptureWriter{ResponseWriter: originalWriter} + c.Writer = responseCapture usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + c.Writer = originalWriter if newAPIError != nil { // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) @@ -150,6 +154,26 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type logContent = append(logContent, fmt.Sprintf("生成数量 %d", imageN)) } - service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), logContent) + summary := service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), logContent) + service.SaveImageGenerationResponse(c, info, request, responseCapture.Bytes(), summary.Quota) return nil } + +type imageResponseCaptureWriter struct { + gin.ResponseWriter + body bytes.Buffer +} + +func (w *imageResponseCaptureWriter) Write(data []byte) (int, error) { + w.body.Write(data) + return w.ResponseWriter.Write(data) +} + +func (w *imageResponseCaptureWriter) WriteString(data string) (int, error) { + w.body.WriteString(data) + return w.ResponseWriter.WriteString(data) +} + +func (w *imageResponseCaptureWriter) Bytes() []byte { + return w.body.Bytes() +} diff --git a/relay/relay_task.go b/relay/relay_task.go index 098e23828b6..6a69dd1ab5c 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -19,6 +19,7 @@ import ( relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/gin-gonic/gin" ) @@ -194,13 +195,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } // 6. 将 OtherRatios 应用到基础额度 - if !common.StringsContains(constant.TaskPricePatches, modelName) { - for _, ra := range info.PriceData.OtherRatios { - if ra != 1.0 { - info.PriceData.Quota = int(float64(info.PriceData.Quota) * ra) - } - } - } + applyTaskBillingRatios(info, modelName) // 7. 预扣费(仅首次 — 重试时 info.Billing 已存在,跳过) if info.Billing == nil && !info.PriceData.FreeModel { @@ -243,8 +238,8 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe // 11. 提交后计费调整:让适配器根据上游实际返回调整 OtherRatios finalQuota := info.PriceData.Quota if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData); len(adjustedRatios) > 0 { - // 基于调整后的 ratios 重新计算 quota - finalQuota = recalcQuotaFromRatios(info, adjustedRatios) + // 基于调整后的 ratios 重新计算 quota;按次模型仅记录倍率,不参与扣费。 + finalQuota = recalcQuotaFromRatios(info, adjustedRatios, ratio_setting.IsTaskPerItemBilling(modelName)) info.PriceData.OtherRatios = adjustedRatios info.PriceData.Quota = finalQuota } @@ -257,9 +252,23 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe }, nil } +func applyTaskBillingRatios(info *relaycommon.RelayInfo, modelName string) { + if ratio_setting.IsTaskPerItemBilling(modelName) { + return + } + for _, ra := range info.PriceData.OtherRatios { + if ra != 1.0 { + info.PriceData.Quota = int(float64(info.PriceData.Quota) * ra) + } + } +} + // recalcQuotaFromRatios 根据 adjustedRatios 重新计算 quota。 // 公式: baseQuota × ∏(ratio) — 其中 baseQuota 是不含 OtherRatios 的基础额度。 -func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float64) int { +func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float64, perCall bool) int { + if perCall { + return info.PriceData.Quota + } // 从 PriceData 获取不含 OtherRatios 的基础价格 baseQuota := info.PriceData.Quota // 先除掉原有的 OtherRatios 恢复基础额度 diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go new file mode 100644 index 00000000000..057e69697c0 --- /dev/null +++ b/relay/relay_task_test.go @@ -0,0 +1,99 @@ +package relay + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyTaskBillingRatiosPerSecondMultipliesSeconds(t *testing.T) { + resetTaskBillingConfig(t) + require.NoError(t, ratio_setting.UpdateTaskBillingUnitByJSONString(`{ + "seedance-480p-fast-c13": "per_second" + }`)) + + info := &relaycommon.RelayInfo{ + PriceData: types.PriceData{ + Quota: 390, + OtherRatios: map[string]float64{ + "seconds": 4, + }, + }, + } + + applyTaskBillingRatios(info, "seedance-480p-fast-c13") + + assert.Equal(t, 1560, info.PriceData.Quota) +} + +func TestApplyTaskBillingRatiosPerItemKeepsBaseQuota(t *testing.T) { + resetTaskBillingConfig(t) + require.NoError(t, ratio_setting.UpdateTaskBillingUnitByJSONString(`{ + "seedance-720p-c37": "per_item" + }`)) + + info := &relaycommon.RelayInfo{ + PriceData: types.PriceData{ + Quota: 390, + OtherRatios: map[string]float64{ + "seconds": 4, + }, + }, + } + + applyTaskBillingRatios(info, "seedance-720p-c37") + + assert.Equal(t, 390, info.PriceData.Quota) +} + +func TestRecalcQuotaFromRatiosPerCallKeepsBaseQuota(t *testing.T) { + info := &relaycommon.RelayInfo{ + PriceData: types.PriceData{ + Quota: 390, + OtherRatios: map[string]float64{ + "seconds": 4, + }, + }, + } + + quota := recalcQuotaFromRatios(info, map[string]float64{ + "seconds": 10, + "size": 1, + }, true) + + assert.Equal(t, 390, quota) +} + +func resetTaskBillingConfig(t *testing.T) { + t.Helper() + original := constant.TaskPricePatches + constant.TaskPricePatches = nil + require.NoError(t, ratio_setting.UpdateTaskBillingUnitByJSONString("{}")) + t.Cleanup(func() { + constant.TaskPricePatches = original + require.NoError(t, ratio_setting.UpdateTaskBillingUnitByJSONString("{}")) + }) +} + +func TestRecalcQuotaFromRatiosNonPerCallAppliesAdjustedRatios(t *testing.T) { + info := &relaycommon.RelayInfo{ + PriceData: types.PriceData{ + Quota: 156, + OtherRatios: map[string]float64{ + "seconds": 4, + }, + }, + } + + quota := recalcQuotaFromRatios(info, map[string]float64{ + "seconds": 10, + "size": 1, + }, false) + + assert.Equal(t, 390, quota) +} diff --git a/router/api-router.go b/router/api-router.go index 83f5e4ae9d9..b461518a258 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -206,6 +206,7 @@ func SetApiRouter(router *gin.Engine) { { ratioSyncRoute.GET("/channels", controller.GetSyncableChannels) ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios) + ratioSyncRoute.POST("/aistarslab/sync", controller.SyncAistarsLabConfig) } channelRoute := apiRouter.Group("/channel") channelRoute.Use(middleware.AdminAuth()) @@ -323,6 +324,9 @@ func SetApiRouter(router *gin.Engine) { mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney) mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney) + imageGenerationRoute := apiRouter.Group("/image-generations") + imageGenerationRoute.GET("/:id/content", controller.GetImageGenerationContent) + taskRoute := apiRouter.Group("/task") { taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) diff --git a/service/aistarslab_config_sync.go b/service/aistarslab_config_sync.go new file mode 100644 index 00000000000..9c0c6b2c229 --- /dev/null +++ b/service/aistarslab_config_sync.go @@ -0,0 +1,692 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" +) + +const ( + aistarslabDefaultConfigURL = "https://api.video.aistarslab.com/openapi/generation/config" + aistarslabDefaultChannelID = 17 + aistarslabDefaultCreditRate = 100 + aistarslabDefaultMarkupRate = 1.3 + aistarslabDefaultIntervalMinutes = 30 + aistarslabRequestTimeout = 30 * time.Second +) + +var ( + aistarslabSeedanceAliasPattern = regexp.MustCompile(`^seedance-[a-z0-9-]+-c[0-9]+$`) + aistarslabRawSeedancePattern = regexp.MustCompile(`^([0-9]+:)?seedance-2\.0`) + aistarslabSyncOnce sync.Once + aistarslabSyncRunning atomic.Bool +) + +type AistarsLabSyncRequest struct { + DryRun bool `json:"dry_run"` + ChannelID int `json:"channel_id"` + ConfigURL string `json:"config_url"` + CreditRate float64 `json:"credit_rate"` + MarkupRate float64 `json:"markup_rate"` +} + +type AistarsLabSyncResult struct { + DryRun bool `json:"dry_run"` + ChannelID int `json:"channel_id"` + ConfigURL string `json:"config_url"` + CreditRate float64 `json:"credit_rate"` + MarkupRate float64 `json:"markup_rate"` + TotalModels int `json:"total_models"` + AddedModels []string `json:"added_models"` + RemovedModels []string `json:"removed_models"` + PriceChanges []AistarsLabPriceChange `json:"price_changes"` + TaskUnitChanges []AistarsLabTaskUnitChange `json:"task_unit_changes"` + MappingChanges []AistarsLabMappingChange `json:"mapping_changes"` + Models []AistarsLabSeedanceModel `json:"models"` +} + +type AistarsLabPriceChange struct { + Model string `json:"model"` + Old *float64 `json:"old,omitempty"` + New *float64 `json:"new,omitempty"` +} + +type AistarsLabTaskUnitChange struct { + Model string `json:"model"` + Old string `json:"old,omitempty"` + New string `json:"new,omitempty"` +} + +type AistarsLabMappingChange struct { + Model string `json:"model"` + Old string `json:"old,omitempty"` + New string `json:"new,omitempty"` +} + +type AistarsLabSeedanceModel struct { + PublicModel string `json:"public_model"` + UpstreamModel string `json:"upstream_model"` + Channel string `json:"channel"` + Quality string `json:"quality"` + BillingUnit string `json:"billing_unit"` + Price float64 `json:"price"` + RawCredits float64 `json:"raw_credits"` + Modes []string `json:"modes,omitempty"` + AspectRatios []string `json:"aspect_ratios,omitempty"` + DurationMin *int `json:"duration_min,omitempty"` + DurationMax *int `json:"duration_max,omitempty"` + InputImagesMax int `json:"input_images_max"` + InputVideosMax int `json:"input_videos_max"` + InputAudiosMax int `json:"input_audios_max"` + DefaultOption bool `json:"default_option"` + SourceTitle string `json:"source_title,omitempty"` + SourceDescription string `json:"source_description,omitempty"` +} + +type aistarsLabConfigResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + VideoConfig []aistarsLabVideoConfig `json:"videoConfig"` + } `json:"data"` +} + +type aistarsLabVideoConfig struct { + Channel string `json:"channel"` + Title string `json:"title"` + Description string `json:"description"` + DefaultOption bool `json:"defaultOption"` + Models []aistarsLabConfigModel `json:"models"` +} + +type aistarsLabConfigModel struct { + Model string `json:"model"` + Label string `json:"label"` + Qualities []aistarsLabQuality `json:"qualities"` + Modes []string `json:"modes"` + AspectRatios []string `json:"aspectRatios"` + Duration aistarsLabDuration `json:"duration"` + InputImagesMax int `json:"inputImagesMax"` + InputVideosMax int `json:"inputVideosMax"` + InputAudiosMax int `json:"inputAudiosMax"` +} + +type aistarsLabQuality struct { + Quality string `json:"quality"` + Pricing struct { + Type string `json:"type"` + Credits float64 `json:"credits"` + } `json:"pricing"` +} + +type aistarsLabDuration struct { + Min *int `json:"min"` + Max *int `json:"max"` + Options []int `json:"options"` +} + +func StartAistarsLabConfigSyncTask() { + aistarslabSyncOnce.Do(func() { + if !common.IsMasterNode { + return + } + if !common.GetEnvOrDefaultBool("AISTARSLAB_CONFIG_SYNC_ENABLED", false) { + common.SysLog("AistarsLab config sync task disabled by AISTARSLAB_CONFIG_SYNC_ENABLED") + return + } + + intervalMinutes := common.GetEnvOrDefault("AISTARSLAB_CONFIG_SYNC_INTERVAL_MINUTES", aistarslabDefaultIntervalMinutes) + if intervalMinutes < 1 { + intervalMinutes = aistarslabDefaultIntervalMinutes + } + interval := time.Duration(intervalMinutes) * time.Minute + + gopool.Go(func() { + common.SysLog(fmt.Sprintf("AistarsLab config sync task started: interval=%s", interval)) + runAistarsLabConfigSyncOnce() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + runAistarsLabConfigSyncOnce() + } + }) + }) +} + +func runAistarsLabConfigSyncOnce() { + if !aistarslabSyncRunning.CompareAndSwap(false, true) { + return + } + defer aistarslabSyncRunning.Store(false) + + result, err := SyncAistarsLabConfig(context.Background(), AistarsLabSyncRequest{}) + if err != nil { + logger.LogError(context.Background(), "AistarsLab config sync failed: "+err.Error()) + return + } + logger.LogInfo(context.Background(), fmt.Sprintf("AistarsLab config sync finished: models=%d added=%d removed=%d price_changes=%d", + result.TotalModels, len(result.AddedModels), len(result.RemovedModels), len(result.PriceChanges))) +} + +func SyncAistarsLabConfig(ctx context.Context, req AistarsLabSyncRequest) (*AistarsLabSyncResult, error) { + normalized := normalizeAistarsLabSyncRequest(req) + apiKey, err := getAistarsLabConfigAPIKey(normalized.ChannelID) + if err != nil { + return nil, err + } + config, err := fetchAistarsLabConfig(ctx, normalized.ConfigURL, apiKey) + if err != nil { + return nil, err + } + models := flattenAistarsLabSeedanceModels(config.Data.VideoConfig, normalized.CreditRate, normalized.MarkupRate) + if len(models) == 0 { + return nil, fmt.Errorf("no seedance models found in AistarsLab config") + } + result := buildAistarsLabSyncResult(normalized, models) + if normalized.DryRun { + return result, nil + } + if err := applyAistarsLabSeedanceSync(normalized.ChannelID, models); err != nil { + return nil, err + } + model.RefreshPricing() + return result, nil +} + +func normalizeAistarsLabSyncRequest(req AistarsLabSyncRequest) AistarsLabSyncRequest { + if req.ChannelID <= 0 { + req.ChannelID = common.GetEnvOrDefault("AISTARSLAB_CONFIG_SYNC_CHANNEL_ID", aistarslabDefaultChannelID) + } + if strings.TrimSpace(req.ConfigURL) == "" { + req.ConfigURL = strings.TrimSpace(common.GetEnvOrDefaultString("AISTARSLAB_CONFIG_URL", aistarslabDefaultConfigURL)) + } + if req.CreditRate <= 0 { + req.CreditRate = getAistarsLabEnvFloat("AISTARSLAB_CREDIT_RATE", aistarslabDefaultCreditRate) + } + if req.MarkupRate <= 0 { + req.MarkupRate = getAistarsLabEnvFloat("AISTARSLAB_MARKUP_RATE", aistarslabDefaultMarkupRate) + } + return req +} + +func getAistarsLabEnvFloat(env string, defaultValue float64) float64 { + raw := strings.TrimSpace(common.GetEnvOrDefaultString(env, "")) + if raw == "" { + return defaultValue + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return defaultValue + } + return value +} + +func getAistarsLabConfigAPIKey(channelID int) (string, error) { + if key := strings.TrimSpace(common.GetEnvOrDefaultString("AISTARSLAB_API_KEY", "")); key != "" { + return strings.TrimPrefix(key, "Bearer "), nil + } + channel, err := model.GetChannelById(channelID, true) + if err != nil { + return "", fmt.Errorf("get sync channel %d failed: %w", channelID, err) + } + key, _, apiErr := channel.GetNextEnabledKey() + if apiErr != nil { + return "", fmt.Errorf("get sync channel key failed: %s", apiErr.Error()) + } + key = strings.TrimSpace(strings.TrimPrefix(key, "Bearer ")) + if key == "" { + return "", fmt.Errorf("AistarsLab API key is empty") + } + return key, nil +} + +func fetchAistarsLabConfig(ctx context.Context, configURL, apiKey string) (*aistarsLabConfigResponse, error) { + ctx, cancel := context.WithTimeout(ctx, aistarslabRequestTimeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, configURL, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("AistarsLab config returned %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, err + } + var parsed aistarsLabConfigResponse + if err := common.Unmarshal(body, &parsed); err != nil { + return nil, err + } + if parsed.Code != 0 { + return nil, fmt.Errorf("AistarsLab config error: code=%d msg=%s", parsed.Code, parsed.Msg) + } + return &parsed, nil +} + +func flattenAistarsLabSeedanceModels(configs []aistarsLabVideoConfig, creditRate, markupRate float64) []AistarsLabSeedanceModel { + byAlias := make(map[string]AistarsLabSeedanceModel) + for _, videoConfig := range configs { + channel := strings.TrimSpace(videoConfig.Channel) + if channel == "" { + continue + } + for _, configModel := range videoConfig.Models { + if !strings.HasPrefix(configModel.Model, "seedance-") { + continue + } + for _, quality := range configModel.Qualities { + billingUnit := aistarsLabBillingUnit(quality.Pricing.Type) + if billingUnit == "" { + continue + } + publicModel := buildAistarsLabSeedanceAlias(configModel.Model, quality.Quality, channel) + if publicModel == "" { + continue + } + item := AistarsLabSeedanceModel{ + PublicModel: publicModel, + UpstreamModel: channel + ":" + configModel.Model, + Channel: channel, + Quality: strings.ToLower(strings.TrimSpace(quality.Quality)), + BillingUnit: billingUnit, + Price: roundAistarsLabPrice(quality.Pricing.Credits / creditRate * markupRate), + RawCredits: quality.Pricing.Credits, + Modes: append([]string(nil), configModel.Modes...), + AspectRatios: append([]string(nil), configModel.AspectRatios...), + DurationMin: configModel.Duration.Min, + DurationMax: configModel.Duration.Max, + InputImagesMax: configModel.InputImagesMax, + InputVideosMax: configModel.InputVideosMax, + InputAudiosMax: configModel.InputAudiosMax, + DefaultOption: videoConfig.DefaultOption, + SourceTitle: videoConfig.Title, + SourceDescription: videoConfig.Description, + } + if existing, ok := byAlias[publicModel]; ok && existing.DefaultOption && !item.DefaultOption { + continue + } + byAlias[publicModel] = item + } + } + } + + models := make([]AistarsLabSeedanceModel, 0, len(byAlias)) + for _, item := range byAlias { + models = append(models, item) + } + sort.Slice(models, func(i, j int) bool { + return models[i].PublicModel < models[j].PublicModel + }) + return models +} + +func buildAistarsLabSeedanceAlias(upstreamModel, quality, channel string) string { + quality = strings.ToLower(strings.TrimSpace(quality)) + upstreamModel = strings.ToLower(strings.TrimSpace(upstreamModel)) + if quality == "" || upstreamModel == "" || channel == "" { + return "" + } + suffix := strings.TrimPrefix(upstreamModel, "seedance-2.0") + suffix = strings.Trim(suffix, "-") + parts := make([]string, 0) + for _, part := range strings.Split(suffix, "-") { + part = strings.TrimSpace(part) + if part == "" || part == quality { + continue + } + parts = append(parts, part) + } + aliasParts := []string{"seedance", quality} + aliasParts = append(aliasParts, parts...) + aliasParts = append(aliasParts, "c"+channel) + return strings.Join(aliasParts, "-") +} + +func aistarsLabBillingUnit(pricingType string) string { + switch strings.TrimSpace(strings.ToLower(pricingType)) { + case "fixed_total": + return ratio_setting.TaskBillingUnitPerItem + case "per_second": + return ratio_setting.TaskBillingUnitPerSecond + default: + return "" + } +} + +func roundAistarsLabPrice(price float64) float64 { + return math.Round(price*100) / 100 +} + +func buildAistarsLabSyncResult(req AistarsLabSyncRequest, models []AistarsLabSeedanceModel) *AistarsLabSyncResult { + result := &AistarsLabSyncResult{ + DryRun: req.DryRun, + ChannelID: req.ChannelID, + ConfigURL: req.ConfigURL, + CreditRate: req.CreditRate, + MarkupRate: req.MarkupRate, + TotalModels: len(models), + Models: models, + } + newPrices := make(map[string]float64, len(models)) + newUnits := make(map[string]string, len(models)) + newMappings := make(map[string]string, len(models)) + for _, item := range models { + newPrices[item.PublicModel] = item.Price + newUnits[item.PublicModel] = item.BillingUnit + newMappings[item.PublicModel] = item.UpstreamModel + } + + oldPrices := ratio_setting.GetModelPriceCopy() + oldUnits := ratio_setting.GetTaskBillingUnitCopy() + oldMappings := getAistarsLabChannelMapping(req.ChannelID) + + for modelName, newPrice := range newPrices { + if oldPrice, ok := oldPrices[modelName]; !ok { + result.AddedModels = append(result.AddedModels, modelName) + price := newPrice + result.PriceChanges = append(result.PriceChanges, AistarsLabPriceChange{Model: modelName, New: &price}) + } else if math.Abs(oldPrice-newPrice) > 1e-9 { + old := oldPrice + price := newPrice + result.PriceChanges = append(result.PriceChanges, AistarsLabPriceChange{Model: modelName, Old: &old, New: &price}) + } + if oldUnit := oldUnits[modelName]; oldUnit != newUnits[modelName] { + result.TaskUnitChanges = append(result.TaskUnitChanges, AistarsLabTaskUnitChange{Model: modelName, Old: oldUnit, New: newUnits[modelName]}) + } + if oldMapping := oldMappings[modelName]; oldMapping != newMappings[modelName] { + result.MappingChanges = append(result.MappingChanges, AistarsLabMappingChange{Model: modelName, Old: oldMapping, New: newMappings[modelName]}) + } + } + for modelName, oldPrice := range oldPrices { + if !isAistarsLabSeedanceAlias(modelName) { + continue + } + if _, ok := newPrices[modelName]; ok { + continue + } + result.RemovedModels = append(result.RemovedModels, modelName) + old := oldPrice + result.PriceChanges = append(result.PriceChanges, AistarsLabPriceChange{Model: modelName, Old: &old}) + } + + sort.Strings(result.AddedModels) + sort.Strings(result.RemovedModels) + sort.Slice(result.PriceChanges, func(i, j int) bool { return result.PriceChanges[i].Model < result.PriceChanges[j].Model }) + sort.Slice(result.TaskUnitChanges, func(i, j int) bool { return result.TaskUnitChanges[i].Model < result.TaskUnitChanges[j].Model }) + sort.Slice(result.MappingChanges, func(i, j int) bool { return result.MappingChanges[i].Model < result.MappingChanges[j].Model }) + return result +} + +func applyAistarsLabSeedanceSync(channelID int, modelsToSync []AistarsLabSeedanceModel) error { + priceMap := ratio_setting.GetModelPriceCopy() + unitMap := ratio_setting.GetTaskBillingUnitCopy() + + currentAliases := make(map[string]struct{}, len(modelsToSync)) + for _, item := range modelsToSync { + currentAliases[item.PublicModel] = struct{}{} + priceMap[item.PublicModel] = item.Price + unitMap[item.PublicModel] = item.BillingUnit + } + for modelName := range priceMap { + if isAistarsLabSeedanceAlias(modelName) { + if _, ok := currentAliases[modelName]; !ok { + delete(priceMap, modelName) + } + } + } + for modelName := range unitMap { + if isAistarsLabSeedanceAlias(modelName) { + if _, ok := currentAliases[modelName]; !ok { + delete(unitMap, modelName) + } + } + } + + priceJSON, err := common.Marshal(priceMap) + if err != nil { + return err + } + unitJSON, err := common.Marshal(unitMap) + if err != nil { + return err + } + if err := model.UpdateOption("ModelPrice", string(priceJSON)); err != nil { + return err + } + if err := model.UpdateOption("TaskBillingUnit", string(unitJSON)); err != nil { + return err + } + if err := upsertAistarsLabSeedanceModelMeta(modelsToSync); err != nil { + return err + } + if err := disableAistarsLabRawSeedanceModelMeta(modelsToSync); err != nil { + return err + } + return updateAistarsLabChannelModels(channelID, modelsToSync) +} + +func upsertAistarsLabSeedanceModelMeta(modelsToSync []AistarsLabSeedanceModel) error { + now := common.GetTimestamp() + activeAliases := make([]string, 0, len(modelsToSync)) + for _, item := range modelsToSync { + activeAliases = append(activeAliases, item.PublicModel) + meta := model.Model{ + ModelName: item.PublicModel, + Description: buildAistarsLabDescription(item), + Icon: "Jimeng.Color", + Tags: buildAistarsLabTags(item), + VendorID: 17, + Status: 1, + SyncOfficial: 0, + UpdatedTime: now, + CreatedTime: now, + NameRule: model.NameRuleExact, + } + var existing model.Model + err := model.DB.Where("model_name = ?", item.PublicModel).First(&existing).Error + if err == nil { + existing.Description = meta.Description + existing.Icon = meta.Icon + existing.Tags = meta.Tags + existing.VendorID = meta.VendorID + existing.Status = meta.Status + existing.SyncOfficial = meta.SyncOfficial + existing.NameRule = meta.NameRule + if err := existing.Update(); err != nil { + return err + } + continue + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err := meta.Insert(); err != nil { + return err + } + } + if len(activeAliases) > 0 { + if err := model.DB.Model(&model.Model{}). + Where("model_name LIKE ? AND model_name NOT IN ?", "seedance-%-c%", activeAliases). + Update("status", 0).Error; err != nil { + return err + } + } + return nil +} + +func disableAistarsLabRawSeedanceModelMeta(modelsToSync []AistarsLabSeedanceModel) error { + now := common.GetTimestamp() + seen := make(map[string]struct{}) + for _, item := range modelsToSync { + rawModel := strings.TrimSpace(item.UpstreamModel) + if rawModel == "" { + continue + } + if _, ok := seen[rawModel]; ok { + continue + } + seen[rawModel] = struct{}{} + meta := model.Model{ + ModelName: rawModel, + Description: "Hidden raw Seedance upstream model", + Icon: "Jimeng.Color", + Tags: "video,seedance,raw", + VendorID: 17, + Status: 0, + SyncOfficial: 0, + UpdatedTime: now, + CreatedTime: now, + NameRule: model.NameRuleExact, + } + var existing model.Model + err := model.DB.Where("model_name = ?", rawModel).First(&existing).Error + if err == nil { + existing.Description = meta.Description + existing.Icon = meta.Icon + existing.Tags = meta.Tags + existing.VendorID = meta.VendorID + existing.Status = 0 + existing.SyncOfficial = meta.SyncOfficial + existing.NameRule = meta.NameRule + if err := existing.Update(); err != nil { + return err + } + continue + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err := meta.Insert(); err != nil { + return err + } + } + return nil +} + +func buildAistarsLabDescription(item AistarsLabSeedanceModel) string { + unit := "按秒计费" + if item.BillingUnit == ratio_setting.TaskBillingUnitPerItem { + unit = "按条计费" + } + return fmt.Sprintf("Seedance 2.0 %s,渠道 %s,%s", item.Quality, item.Channel, unit) +} + +func buildAistarsLabTags(item AistarsLabSeedanceModel) string { + tags := []string{"video", "seedance"} + if item.BillingUnit == ratio_setting.TaskBillingUnitPerItem { + tags = append(tags, "按条") + } else { + tags = append(tags, "按秒") + } + if strings.Contains(item.PublicModel, "fast") { + tags = append(tags, "fast") + } + if strings.Contains(item.PublicModel, "4img") { + tags = append(tags, "4img") + } + if common.StringsContains(item.Modes, "frames2video") { + tags = append(tags, "首尾帧") + } + return strings.Join(tags, ",") +} + +func updateAistarsLabChannelModels(channelID int, modelsToSync []AistarsLabSeedanceModel) error { + channel, err := model.GetChannelById(channelID, true) + if err != nil { + return err + } + models := filterOutAistarsLabSeedanceAliases(channel.GetModels()) + mapping := parseStringMap(channel.GetModelMapping()) + + for key := range mapping { + if isAistarsLabSeedanceAlias(key) || isAistarsLabRawSeedanceModel(key) { + delete(mapping, key) + } + } + for _, item := range modelsToSync { + models = append(models, item.PublicModel) + mapping[item.PublicModel] = item.UpstreamModel + } + sort.Strings(models) + mappingBytes, err := common.Marshal(mapping) + if err != nil { + return err + } + mappingStr := string(mappingBytes) + channel.Models = strings.Join(models, ",") + channel.ModelMapping = &mappingStr + if err := channel.Update(); err != nil { + return err + } + return nil +} + +func filterOutAistarsLabSeedanceAliases(models []string) []string { + out := make([]string, 0, len(models)) + seen := make(map[string]struct{}) + for _, modelName := range models { + modelName = strings.TrimSpace(modelName) + if modelName == "" || isAistarsLabSeedanceAlias(modelName) || isAistarsLabRawSeedanceModel(modelName) { + continue + } + if _, ok := seen[modelName]; ok { + continue + } + seen[modelName] = struct{}{} + out = append(out, modelName) + } + return out +} + +func getAistarsLabChannelMapping(channelID int) map[string]string { + channel, err := model.GetChannelById(channelID, true) + if err != nil { + return map[string]string{} + } + return parseStringMap(channel.GetModelMapping()) +} + +func parseStringMap(raw string) map[string]string { + result := make(map[string]string) + if strings.TrimSpace(raw) == "" { + return result + } + _ = common.Unmarshal([]byte(raw), &result) + return result +} + +func isAistarsLabSeedanceAlias(modelName string) bool { + return aistarslabSeedanceAliasPattern.MatchString(modelName) +} + +func isAistarsLabRawSeedanceModel(modelName string) bool { + return aistarslabRawSeedancePattern.MatchString(strings.TrimSpace(modelName)) +} diff --git a/service/aistarslab_config_sync_test.go b/service/aistarslab_config_sync_test.go new file mode 100644 index 00000000000..798a6523822 --- /dev/null +++ b/service/aistarslab_config_sync_test.go @@ -0,0 +1,131 @@ +package service + +import ( + "testing" + + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/stretchr/testify/assert" +) + +func TestFlattenAistarsLabSeedanceModels(t *testing.T) { + durationMin := 4 + durationMax := 15 + configs := []aistarsLabVideoConfig{ + { + Channel: "12", + Title: "视频-Seedance2.0-720P-推荐1", + DefaultOption: true, + Models: []aistarsLabConfigModel{ + { + Model: "seedance-2.0-720p-fast", + Modes: []string{"text2video", "image2video"}, + AspectRatios: []string{"16:9", "9:16"}, + Duration: aistarsLabDuration{ + Min: &durationMin, + Max: &durationMax, + }, + InputImagesMax: 9, + InputVideosMax: 3, + InputAudiosMax: 3, + Qualities: []aistarsLabQuality{ + { + Quality: "720p", + Pricing: struct { + Type string `json:"type"` + Credits float64 `json:"credits"` + }{ + Type: "per_second", + Credits: 44, + }, + }, + }, + }, + { + Model: "seedance-2.0-720p", + Qualities: []aistarsLabQuality{ + { + Quality: "720p", + Pricing: struct { + Type string `json:"type"` + Credits float64 `json:"credits"` + }{ + Type: "per_second", + Credits: 52, + }, + }, + }, + }, + }, + }, + { + Channel: "37", + Models: []aistarsLabConfigModel{ + { + Model: "seedance-2.0-720p-fast", + Qualities: []aistarsLabQuality{ + { + Quality: "720p", + Pricing: struct { + Type string `json:"type"` + Credits float64 `json:"credits"` + }{ + Type: "fixed_total", + Credits: 350, + }, + }, + }, + }, + { + Model: "seedance-2.0", + Qualities: []aistarsLabQuality{ + { + Quality: "4k", + Pricing: struct { + Type string `json:"type"` + Credits float64 `json:"credits"` + }{ + Type: "per_second", + Credits: 260, + }, + }, + }, + }, + }, + }, + } + + models := flattenAistarsLabSeedanceModels(configs, 100, 1.3) + + assert.Len(t, models, 4) + byName := make(map[string]AistarsLabSeedanceModel) + for _, item := range models { + byName[item.PublicModel] = item + } + assert.Equal(t, "12:seedance-2.0-720p-fast", byName["seedance-720p-fast-c12"].UpstreamModel) + assert.Equal(t, ratio_setting.TaskBillingUnitPerSecond, byName["seedance-720p-fast-c12"].BillingUnit) + assert.Equal(t, 0.57, byName["seedance-720p-fast-c12"].Price) + assert.Equal(t, 0.68, byName["seedance-720p-c12"].Price) + assert.Equal(t, ratio_setting.TaskBillingUnitPerItem, byName["seedance-720p-fast-c37"].BillingUnit) + assert.Equal(t, 4.55, byName["seedance-720p-fast-c37"].Price) + assert.Equal(t, "37:seedance-2.0", byName["seedance-4k-c37"].UpstreamModel) + assert.Equal(t, 3.38, byName["seedance-4k-c37"].Price) + assert.Equal(t, &durationMin, byName["seedance-720p-fast-c12"].DurationMin) +} + +func TestBuildAistarsLabSeedanceAlias(t *testing.T) { + assert.Equal(t, "seedance-720p-fast-c12", buildAistarsLabSeedanceAlias("seedance-2.0-720p-fast", "720p", "12")) + assert.Equal(t, "seedance-1080p-c30", buildAistarsLabSeedanceAlias("seedance-2.0", "1080p", "30")) + assert.Equal(t, "seedance-720p-fast-4img-c18", buildAistarsLabSeedanceAlias("seedance-2.0-720p-fast-4img", "720p", "18")) +} + +func TestFilterOutAistarsLabSeedanceAliasesRemovesRawModels(t *testing.T) { + filtered := filterOutAistarsLabSeedanceAliases([]string{ + "grok-video-1.5", + "seedance-720p-fast-c12", + "12:seedance-2.0-720p-fast", + "seedance-2.0-720p", + "grok-video-1.5", + }) + + assert.Equal(t, []string{"grok-video-1.5"}, filtered) +} diff --git a/service/image_generation_storage.go b/service/image_generation_storage.go new file mode 100644 index 00000000000..b51ac76eb82 --- /dev/null +++ b/service/image_generation_storage.go @@ -0,0 +1,196 @@ +package service + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + + "github.com/gin-gonic/gin" +) + +const imageGenerationRetention = 7 * 24 * time.Hour + +func imageGenerationStorageDir() string { + if dir := strings.TrimSpace(os.Getenv("IMAGE_GENERATION_STORAGE_DIR")); dir != "" { + return dir + } + if info, err := os.Stat("/data"); err == nil && info.IsDir() { + return "/data/image-generations" + } + return "data/image-generations" +} + +func imageGenerationFilePath(relativePath string) string { + cleanPath := filepath.Clean(relativePath) + if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(os.PathSeparator)) { + return filepath.Join(imageGenerationStorageDir(), "_invalid") + } + return filepath.Join(imageGenerationStorageDir(), cleanPath) +} + +func SaveImageGenerationResponse(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ImageRequest, responseBody []byte, quota int) { + if len(responseBody) == 0 || request == nil || info == nil { + return + } + + var imageResponse dto.ImageResponse + if err := common.Unmarshal(responseBody, &imageResponse); err != nil { + logger.LogWarn(c, "failed to parse image generation response for storage: "+err.Error()) + return + } + if len(imageResponse.Data) == 0 { + return + } + + now := time.Now() + useTimeSeconds := int64(0) + if !info.StartTime.IsZero() { + useTimeSeconds = int64(now.Sub(info.StartTime).Seconds()) + if useTimeSeconds < 0 { + useTimeSeconds = 0 + } + } + requestID := c.GetString(common.RequestIdKey) + if requestID == "" { + requestID = common.GetUUID() + } + perImageQuota := quota + if len(imageResponse.Data) > 0 { + perImageQuota = quota / len(imageResponse.Data) + } + + for index, item := range imageResponse.Data { + if strings.TrimSpace(item.B64Json) == "" { + continue + } + mimeType, ext, raw, err := decodeImageGenerationBase64(item.B64Json) + if err != nil { + logger.LogWarn(c, fmt.Sprintf("failed to decode image generation response image %d: %s", index, err.Error())) + continue + } + + relativeDir := filepath.Join(now.Format("20060102"), fmt.Sprintf("user-%d", info.UserId)) + filename := fmt.Sprintf("%s-%d.%s", requestID, index, ext) + relativePath := filepath.Join(relativeDir, filename) + absolutePath := imageGenerationFilePath(relativePath) + if err := os.MkdirAll(filepath.Dir(absolutePath), 0750); err != nil { + logger.LogError(c, "failed to create image generation storage dir: "+err.Error()) + continue + } + if err := os.WriteFile(absolutePath, raw, 0600); err != nil { + logger.LogError(c, "failed to write image generation file: "+err.Error()) + continue + } + + recordQuota := perImageQuota + if index == len(imageResponse.Data)-1 { + recordQuota = quota - perImageQuota*(len(imageResponse.Data)-1) + } + quality := request.Quality + if quality == "" { + quality = "standard" + } + record := &model.ImageGeneration{ + UserId: info.UserId, + TokenId: info.TokenId, + ChannelId: info.ChannelId, + RequestId: requestID, + ImageIndex: index, + ModelName: info.OriginModelName, + Prompt: request.Prompt, + Size: request.Size, + Quality: quality, + Quota: recordQuota, + FilePath: relativePath, + MimeType: mimeType, + Status: model.ImageGenerationStatusSuccess, + Group: info.UsingGroup, + CreatedAt: now.Unix(), + UseTime: useTimeSeconds, + ExpireAt: now.Add(imageGenerationRetention).Unix(), + } + if err := model.InsertImageGeneration(record); err != nil { + logger.LogError(c, "failed to insert image generation record: "+err.Error()) + _ = os.Remove(absolutePath) + } + } +} + +func decodeImageGenerationBase64(data string) (mimeType string, ext string, raw []byte, err error) { + if commaIndex := strings.Index(data, ","); commaIndex >= 0 { + data = data[commaIndex+1:] + } + raw, err = base64.StdEncoding.DecodeString(strings.TrimSpace(data)) + if err != nil { + return "", "", nil, err + } + mimeType = http.DetectContentType(raw) + switch mimeType { + case "image/png": + ext = "png" + case "image/jpeg": + ext = "jpg" + case "image/webp": + ext = "webp" + case "image/gif": + ext = "gif" + default: + if strings.HasPrefix(mimeType, "image/") { + ext = strings.TrimPrefix(mimeType, "image/") + } else { + mimeType = "image/png" + ext = "png" + } + } + return mimeType, ext, raw, nil +} + +func StartImageGenerationCleanupTask() { + go func() { + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + CleanupExpiredImageGenerations() + <-ticker.C + } + }() +} + +func CleanupExpiredImageGenerations() { + for { + records, err := model.GetExpiredImageGenerations(time.Now().Unix(), 100) + if err != nil { + logger.LogError(context.Background(), "failed to query expired image generations: "+err.Error()) + return + } + if len(records) == 0 { + return + } + for _, record := range records { + if record.FilePath != "" { + _ = os.Remove(imageGenerationFilePath(record.FilePath)) + } + if err := model.MarkImageGenerationExpired(record.Id); err != nil { + logger.LogError(context.Background(), "failed to mark image generation expired: "+err.Error()) + } + } + } +} + +func GetImageGenerationAbsolutePath(record *model.ImageGeneration) string { + if record == nil || record.FilePath == "" { + return "" + } + return imageGenerationFilePath(record.FilePath) +} diff --git a/service/image_generation_storage_test.go b/service/image_generation_storage_test.go new file mode 100644 index 00000000000..69abe3c40c5 --- /dev/null +++ b/service/image_generation_storage_test.go @@ -0,0 +1,104 @@ +package service + +import ( + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSaveImageGenerationResponseStoresFileAndRecord(t *testing.T) { + truncateServiceImageGenerationTables(t) + + storageDir := t.TempDir() + t.Setenv("IMAGE_GENERATION_STORAGE_DIR", storageDir) + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(common.RequestIdKey, "req_image_store") + + responseBody, err := common.Marshal(dto.ImageResponse{ + Data: []dto.ImageData{ + {B64Json: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="}, + }, + }) + require.NoError(t, err) + + relayInfo := &relaycommon.RelayInfo{ + UserId: 7, + TokenId: 8, + OriginModelName: "gemini-3.1-flash-image", + UsingGroup: "Image", + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelId: 9, + }, + } + request := &dto.ImageRequest{ + Prompt: "a red cube", + Size: "1024x1024", + Quality: "standard", + } + + SaveImageGenerationResponse(c, relayInfo, request, responseBody, 50000) + + var records []model.ImageGeneration + require.NoError(t, model.DB.Find(&records).Error) + require.Len(t, records, 1) + require.Equal(t, "req_image_store", records[0].RequestId) + require.Equal(t, "gemini-3.1-flash-image", records[0].ModelName) + require.Equal(t, "a red cube", records[0].Prompt) + require.Equal(t, "1024x1024", records[0].Size) + require.Equal(t, 50000, records[0].Quota) + require.Equal(t, model.ImageGenerationStatusSuccess, records[0].Status) + require.NotEmpty(t, records[0].FilePath) + + _, err = os.Stat(filepath.Join(storageDir, records[0].FilePath)) + require.NoError(t, err) +} + +func TestCleanupExpiredImageGenerationsDeletesFileAndMarksExpired(t *testing.T) { + truncateServiceImageGenerationTables(t) + + storageDir := t.TempDir() + t.Setenv("IMAGE_GENERATION_STORAGE_DIR", storageDir) + + relativePath := filepath.Join("20260710", "user-1", "expired.png") + absolutePath := filepath.Join(storageDir, relativePath) + require.NoError(t, os.MkdirAll(filepath.Dir(absolutePath), 0750)) + require.NoError(t, os.WriteFile(absolutePath, []byte("png"), 0600)) + + record := &model.ImageGeneration{ + UserId: 1, + RequestId: "req_expired", + FilePath: relativePath, + Status: model.ImageGenerationStatusSuccess, + CreatedAt: time.Now().Add(-8 * 24 * time.Hour).Unix(), + ExpireAt: time.Now().Add(-time.Hour).Unix(), + } + require.NoError(t, model.DB.Create(record).Error) + + CleanupExpiredImageGenerations() + + _, err := os.Stat(absolutePath) + require.True(t, os.IsNotExist(err)) + + var reloaded model.ImageGeneration + require.NoError(t, model.DB.First(&reloaded, record.Id).Error) + require.Equal(t, model.ImageGenerationStatusExpired, reloaded.Status) + require.Empty(t, reloaded.FilePath) +} + +func truncateServiceImageGenerationTables(t *testing.T) { + t.Helper() + require.NoError(t, model.DB.Exec("DELETE FROM image_generations").Error) +} diff --git a/service/task_billing.go b/service/task_billing.go index 6cf7a965c8e..5abfcc54bd4 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -5,8 +5,6 @@ import ( "fmt" "strings" - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -20,7 +18,7 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { tokenName := c.GetString("token_name") logContent := fmt.Sprintf("操作 %s", info.Action) // 支持任务仅按次计费 - if common.StringsContains(constant.TaskPricePatches, info.OriginModelName) { + if ratio_setting.IsTaskPerItemBilling(info.OriginModelName) { logContent = fmt.Sprintf("%s,按次计费", logContent) } else { if len(info.PriceData.OtherRatios) > 0 { diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 39cb8f1da1a..981fcc9543e 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -10,6 +10,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" @@ -42,6 +43,7 @@ func TestMain(m *testing.M) { &model.Token{}, &model.Log{}, &model.Channel{}, + &model.ImageGeneration{}, &model.TopUp{}, &model.UserSubscription{}, ); err != nil { @@ -714,3 +716,58 @@ func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) { require.NotNil(t, log) assert.Equal(t, model.LogTypeRefund, log.Type) } + +func TestNormalizeNestedSuccessfulVideoTask(t *testing.T) { + taskResult := &relaycommon.TaskInfo{ + Status: model.TaskStatusFailure, + Reason: "upstream returned unrecognized message", + Url: "upstream returned unrecognized message", + } + data := []byte(`{ + "status": "done", + "progress": 100, + "video_url": "https://vidgen.x.ai/example.mp4" + }`) + + normalizeNestedSuccessfulVideoTask(taskResult, data) + + assert.Equal(t, model.TaskStatusSuccess, taskResult.Status) + assert.Equal(t, "", taskResult.Reason) + assert.Equal(t, "https://vidgen.x.ai/example.mp4", taskResult.Url) + assert.Equal(t, taskcommon.ProgressComplete, taskResult.Progress) +} + +func TestNormalizeNestedSuccessfulVideoTaskIgnoresRealFailure(t *testing.T) { + taskResult := &relaycommon.TaskInfo{ + Status: model.TaskStatusFailure, + Reason: "upstream returned unrecognized message", + } + data := []byte(`{ + "status": "failed", + "video_url": "https://vidgen.x.ai/example.mp4" + }`) + + normalizeNestedSuccessfulVideoTask(taskResult, data) + + assert.Equal(t, model.TaskStatusFailure, taskResult.Status) + assert.Equal(t, "upstream returned unrecognized message", taskResult.Reason) + assert.Equal(t, "", taskResult.Url) +} + +func TestSuccessfulNestedVideoURLFromRawUpstreamResponse(t *testing.T) { + data := []byte(`{ + "model": "grok-image-video", + "progress": 100, + "status": "done", + "video": { + "url": "https://vidgen.x.ai/video-from-nested-video.mp4" + }, + "output": ["https://vidgen.x.ai/video-from-output.mp4"], + "video_url": "https://vidgen.x.ai/video-from-video-url.mp4" + }`) + + url, ok := successfulNestedVideoURL(data) + + require.True(t, ok) + assert.Equal(t, "https://vidgen.x.ai/video-from-video-url.mp4", url) +} diff --git a/service/task_polling.go b/service/task_polling.go index dc85e579e8c..d7187e030cb 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -388,6 +388,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * taskResult.Progress = t.Progress taskResult.Reason = t.FailReason task.Data = t.Data + normalizeNestedSuccessfulVideoTask(taskResult, t.Data) } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil { return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) } @@ -397,6 +398,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask taskResult: %+v", taskResult)) now := time.Now().Unix() + if taskResult.Status == "" { + if url, ok := successfulNestedVideoURL(responseBody); ok { + taskResult.Status = model.TaskStatusSuccess + taskResult.Url = url + taskResult.Progress = taskcommon.ProgressComplete + } + } if taskResult.Status == "" { //taskResult = relaycommon.FailTaskInfo("upstream returned empty status") errorResult := &dto.GeneralErrorResponse{} @@ -501,6 +509,74 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * return nil } +func normalizeNestedSuccessfulVideoTask(taskResult *relaycommon.TaskInfo, data []byte) { + if taskResult == nil || taskResult.Status != model.TaskStatusFailure { + return + } + if !strings.Contains(taskResult.Reason, "upstream returned unrecognized message") { + return + } + + url, ok := successfulNestedVideoURL(data) + if !ok { + return + } + + taskResult.Status = model.TaskStatusSuccess + taskResult.Reason = "" + taskResult.Url = url + taskResult.Progress = taskcommon.ProgressComplete +} + +func successfulNestedVideoURL(data []byte) (string, bool) { + var nested map[string]any + if err := common.Unmarshal(data, &nested); err != nil { + return "", false + } + if !isSuccessfulUpstreamTaskStatus(stringValue(nested["status"])) { + return "", false + } + return extractNestedVideoURL(nested) +} + +func isSuccessfulUpstreamTaskStatus(status string) bool { + switch strings.ToLower(status) { + case "success", "succeeded", "completed", "complete", "done": + return true + default: + return false + } +} + +func extractNestedVideoURL(nested map[string]any) (string, bool) { + for _, key := range []string{"video_url", "result_url", "url"} { + if value := stringValue(nested[key]); value != "" { + return value, true + } + } + + if output, ok := nested["output"].([]any); ok { + for _, item := range output { + if value := stringValue(item); value != "" { + return value, true + } + } + } + + if video, ok := nested["video"].(map[string]any); ok { + if value := stringValue(video["url"]); value != "" { + return value, true + } + } + + return "", false +} + +func stringValue(v any) string { + s, _ := v.(string) + return s +} + func redactVideoResponseBody(body []byte) []byte { var m map[string]any if err := common.Unmarshal(body, &m); err != nil { diff --git a/service/text_quota.go b/service/text_quota.go index 8caee8f2879..c670e98367c 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -291,7 +291,7 @@ func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) return "openai" } -func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) { +func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) textQuotaSummary { originUsage := usage if usage == nil { extraContent = append(extraContent, "上游无计费信息") @@ -427,4 +427,5 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us Group: relayInfo.UsingGroup, Other: other, }) + return summary } diff --git a/service/text_quota_test.go b/service/text_quota_test.go index e995de17ae8..fbfe80d46ea 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -316,3 +316,33 @@ func TestCalculateTextQuotaSummaryKeepsPrePRClaudeOpenRouterBilling(t *testing.T require.Equal(t, 172, summary.PromptTokens) require.Equal(t, 798, summary.Quota) } + +func TestCalculateTextQuotaSummaryAppliesImageUnitPriceWithCountAndGroupRatio(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "gemini-3.1-flash-image", + PriceData: types.PriceData{ + UsePrice: true, + ModelPrice: 0.101, + OtherRatios: map[string]float64{ + "n": 3, + }, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: 1.5, + }, + }, + StartTime: time.Now(), + } + + usage := &dto.Usage{ + PromptTokens: 1, + TotalTokens: 1, + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, usage) + + require.Equal(t, 227250, summary.Quota) +} diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go index c88216fcb01..902ec89a4af 100644 --- a/setting/ratio_setting/exposed_cache.go +++ b/setting/ratio_setting/exposed_cache.go @@ -47,6 +47,7 @@ func GetExposedData() gin.H { "cache_ratio": GetCacheRatioCopy(), "create_cache_ratio": GetCreateCacheRatioCopy(), "model_price": GetModelPriceCopy(), + "task_billing_unit": GetEffectiveTaskBillingUnitCopy(), } exposedData.Store(&exposedCache{ data: newData, diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 7556fd9482c..3a4a0768cf2 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -443,18 +443,14 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error { func GetCompletionRatio(name string) float64 { name = FormatMatchingModelName(name) - if strings.Contains(name, "/") { - if ratio, ok := completionRatioMap.Get(name); ok { - return ratio - } + if ratio, ok := completionRatioMap.Get(name); ok { + return ratio } + hardCodedRatio, contain := getHardcodedCompletionModelRatio(name) if contain { return hardCodedRatio } - if ratio, ok := completionRatioMap.Get(name); ok { - return ratio - } return hardCodedRatio } @@ -466,12 +462,10 @@ type CompletionRatioInfo struct { func GetCompletionRatioInfo(name string) CompletionRatioInfo { name = FormatMatchingModelName(name) - if strings.Contains(name, "/") { - if ratio, ok := completionRatioMap.Get(name); ok { - return CompletionRatioInfo{ - Ratio: ratio, - Locked: false, - } + if ratio, ok := completionRatioMap.Get(name); ok { + return CompletionRatioInfo{ + Ratio: ratio, + Locked: false, } } @@ -483,13 +477,6 @@ func GetCompletionRatioInfo(name string) CompletionRatioInfo { } } - if ratio, ok := completionRatioMap.Get(name); ok { - return CompletionRatioInfo{ - Ratio: ratio, - Locked: false, - } - } - return CompletionRatioInfo{ Ratio: hardCodedRatio, Locked: false, diff --git a/setting/ratio_setting/model_ratio_test.go b/setting/ratio_setting/model_ratio_test.go new file mode 100644 index 00000000000..38a5530b494 --- /dev/null +++ b/setting/ratio_setting/model_ratio_test.go @@ -0,0 +1,53 @@ +package ratio_setting + +import "testing" + +func TestConfiguredCompletionRatioOverridesHardcodedGPT5Ratio(t *testing.T) { + originalCompletionRatio := CompletionRatio2JSONString() + t.Cleanup(func() { + if err := UpdateCompletionRatioByJSONString(originalCompletionRatio); err != nil { + t.Fatalf("restore completion ratio: %v", err) + } + }) + + if err := UpdateCompletionRatioByJSONString(`{"gpt-5.5":6}`); err != nil { + t.Fatalf("update completion ratio: %v", err) + } + + if got := GetCompletionRatio("gpt-5.5"); got != 6 { + t.Fatalf("GetCompletionRatio() = %v, want 6", got) + } + + info := GetCompletionRatioInfo("gpt-5.5") + if info.Ratio != 6 { + t.Fatalf("GetCompletionRatioInfo().Ratio = %v, want 6", info.Ratio) + } + if info.Locked { + t.Fatal("GetCompletionRatioInfo().Locked = true, want false for configured ratio") + } +} + +func TestHardcodedCompletionRatioAppliesWhenGPT5RatioIsNotConfigured(t *testing.T) { + originalCompletionRatio := CompletionRatio2JSONString() + t.Cleanup(func() { + if err := UpdateCompletionRatioByJSONString(originalCompletionRatio); err != nil { + t.Fatalf("restore completion ratio: %v", err) + } + }) + + if err := UpdateCompletionRatioByJSONString(`{}`); err != nil { + t.Fatalf("update completion ratio: %v", err) + } + + if got := GetCompletionRatio("gpt-5.5"); got != 8 { + t.Fatalf("GetCompletionRatio() = %v, want 8", got) + } + + info := GetCompletionRatioInfo("gpt-5.5") + if info.Ratio != 8 { + t.Fatalf("GetCompletionRatioInfo().Ratio = %v, want 8", info.Ratio) + } + if !info.Locked { + t.Fatal("GetCompletionRatioInfo().Locked = false, want true for hardcoded ratio") + } +} diff --git a/setting/ratio_setting/task_billing_unit.go b/setting/ratio_setting/task_billing_unit.go new file mode 100644 index 00000000000..df157d20d8f --- /dev/null +++ b/setting/ratio_setting/task_billing_unit.go @@ -0,0 +1,104 @@ +package ratio_setting + +import ( + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/types" +) + +const ( + TaskBillingUnitPerItem = "per_item" + TaskBillingUnitPerSecond = "per_second" +) + +var taskBillingUnitMap = types.NewRWMap[string, string]() + +func TaskBillingUnit2JSONString() string { + units := GetTaskBillingUnitCopy() + return taskBillingUnitsToJSONString(units) +} + +func EffectiveTaskBillingUnit2JSONString() string { + units := GetEffectiveTaskBillingUnitCopy() + return taskBillingUnitsToJSONString(units) +} + +func taskBillingUnitsToJSONString(units map[string]string) string { + bytes, err := common.Marshal(units) + if err != nil { + return "{}" + } + return string(bytes) +} + +func UpdateTaskBillingUnitByJSONString(jsonStr string) error { + raw := make(map[string]string) + if err := common.Unmarshal([]byte(jsonStr), &raw); err != nil { + return err + } + taskBillingUnitMap.Clear() + for model, unit := range raw { + normalized := NormalizeTaskBillingUnit(unit) + if normalized == "" { + continue + } + taskBillingUnitMap.Set(model, normalized) + } + InvalidateExposedDataCache() + return nil +} + +func GetTaskBillingUnitCopy() map[string]string { + units := taskBillingUnitMap.ReadAll() + for _, modelName := range constant.TaskPricePatches { + if _, ok := units[modelName]; !ok { + units[modelName] = TaskBillingUnitPerItem + } + } + return units +} + +func GetEffectiveTaskBillingUnitCopy() map[string]string { + units := GetTaskBillingUnitCopy() + for modelName := range modelPriceMap.ReadAll() { + if _, ok := units[modelName]; ok { + continue + } + if IsTaskPerSecondBilling(modelName) { + units[modelName] = TaskBillingUnitPerSecond + } + } + return units +} + +func GetTaskBillingUnit(modelName string) (string, bool) { + modelName = FormatMatchingModelName(modelName) + return taskBillingUnitMap.Get(modelName) +} + +func NormalizeTaskBillingUnit(unit string) string { + switch strings.TrimSpace(strings.ToLower(unit)) { + case TaskBillingUnitPerItem: + return TaskBillingUnitPerItem + case TaskBillingUnitPerSecond: + return TaskBillingUnitPerSecond + default: + return "" + } +} + +func IsTaskPerItemBilling(modelName string) bool { + if unit, ok := GetTaskBillingUnit(modelName); ok { + return unit == TaskBillingUnitPerItem + } + return common.StringsContains(constant.TaskPricePatches, modelName) +} + +func IsTaskPerSecondBilling(modelName string) bool { + if unit, ok := GetTaskBillingUnit(modelName); ok { + return unit == TaskBillingUnitPerSecond + } + return strings.HasPrefix(modelName, "seedance-") && !common.StringsContains(constant.TaskPricePatches, modelName) +} diff --git a/setting/ratio_setting/task_billing_unit_test.go b/setting/ratio_setting/task_billing_unit_test.go new file mode 100644 index 00000000000..e972c39020f --- /dev/null +++ b/setting/ratio_setting/task_billing_unit_test.go @@ -0,0 +1,56 @@ +package ratio_setting + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTaskBillingUnitExplicitConfigOverridesTaskPricePatch(t *testing.T) { + original := constant.TaskPricePatches + t.Cleanup(func() { + constant.TaskPricePatches = original + require.NoError(t, UpdateTaskBillingUnitByJSONString("{}")) + }) + + constant.TaskPricePatches = []string{"seedance-480p-fast-c13"} + require.NoError(t, UpdateTaskBillingUnitByJSONString(`{ + "seedance-480p-fast-c13": "per_second", + "seedance-720p-c37": "per_item" + }`)) + + assert.False(t, IsTaskPerItemBilling("seedance-480p-fast-c13")) + assert.True(t, IsTaskPerSecondBilling("seedance-480p-fast-c13")) + assert.True(t, IsTaskPerItemBilling("seedance-720p-c37")) + assert.False(t, IsTaskPerSecondBilling("seedance-720p-c37")) +} + +func TestTaskBillingUnitFallsBackToTaskPricePatch(t *testing.T) { + original := constant.TaskPricePatches + t.Cleanup(func() { + constant.TaskPricePatches = original + require.NoError(t, UpdateTaskBillingUnitByJSONString("{}")) + }) + + constant.TaskPricePatches = []string{"seedance-720p-c37"} + require.NoError(t, UpdateTaskBillingUnitByJSONString("{}")) + + assert.True(t, IsTaskPerItemBilling("seedance-720p-c37")) + assert.False(t, IsTaskPerSecondBilling("seedance-720p-c37")) +} + +func TestTaskBillingUnitFallsBackToSeedancePerSecond(t *testing.T) { + original := constant.TaskPricePatches + t.Cleanup(func() { + constant.TaskPricePatches = original + require.NoError(t, UpdateTaskBillingUnitByJSONString("{}")) + }) + + constant.TaskPricePatches = []string{"seedance-720p-c37"} + require.NoError(t, UpdateTaskBillingUnitByJSONString("{}")) + + assert.False(t, IsTaskPerItemBilling("seedance-480p-fast-c13")) + assert.True(t, IsTaskPerSecondBilling("seedance-480p-fast-c13")) +} diff --git a/types/request_meta.go b/types/request_meta.go index 476ea0524df..d8c748f4f55 100644 --- a/types/request_meta.go +++ b/types/request_meta.go @@ -26,7 +26,9 @@ type TokenCountMeta struct { Files []*FileMeta `json:"files,omitempty"` // List of files, each with type and content MaxTokens int `json:"max_tokens,omitempty"` // Maximum tokens allowed in the request - ImagePriceRatio float64 `json:"image_ratio,omitempty"` // Ratio for image size, if applicable + ImagePriceRatio float64 `json:"image_ratio,omitempty"` // Ratio for image size, if applicable + ImageUnitPrice float64 `json:"image_unit_price,omitempty"` + ImageGroupUnitPrice float64 `json:"image_group_unit_price,omitempty"` //IsStreaming bool `json:"is_streaming,omitempty"` // Indicates if the request is streaming } diff --git a/web/src/components/table/mj-logs/MjLogsColumnDefs.jsx b/web/src/components/table/mj-logs/MjLogsColumnDefs.jsx index 9fa26efe0b9..d9bf445b7e0 100644 --- a/web/src/components/table/mj-logs/MjLogsColumnDefs.jsx +++ b/web/src/components/table/mj-logs/MjLogsColumnDefs.jsx @@ -74,6 +74,12 @@ function renderType(type, t) { {t('绘图')} ); + case 'IMAGE_GENERATION': + return ( + }> + {t('图片生成')} + + ); case 'UPSCALE': return ( }> @@ -268,6 +274,12 @@ function renderStatus(type, t) { {t('失败')} ); + case 'EXPIRED': + return ( + }> + {t('已过期')} + + ); case 'MODAL': return ( {text || '-'} diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 477da259d7a..60171938a51 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -165,6 +165,12 @@ const PricingCardView = ({ {t('按次计费')} ); + } else if (record.quota_type === 2) { + billingTag = ( + + {t('按秒计费')} + + ); } else if (record.quota_type === 0) { billingTag = ( diff --git a/web/src/components/table/model-pricing/view/table/PricingTableColumns.jsx b/web/src/components/table/model-pricing/view/table/PricingTableColumns.jsx index 8b61d5c80cc..1b9850d4a93 100644 --- a/web/src/components/table/model-pricing/view/table/PricingTableColumns.jsx +++ b/web/src/components/table/model-pricing/view/table/PricingTableColumns.jsx @@ -41,6 +41,12 @@ function renderQuotaType(type, t) { {t('按次计费')} ); + case 2: + return ( + + {t('按秒计费')} + + ); case 0: return ( diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index 435a11ed3d1..ed427b55067 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -737,13 +737,14 @@ export const calculateModelPrice = ({ }; } - if (record.quota_type === 1) { - // 按次计费 + if (record.quota_type === 1 || record.quota_type === 2) { + // 按次/按秒计费 const priceUSD = parseFloat(record.model_price) * usedGroupRatio; const displayVal = displayPrice(priceUSD); return { price: displayVal, + fixedUnit: record.quota_type === 2 ? 'second' : 'request', isPerToken: false, isTokensDisplay: false, usedGroup, @@ -869,7 +870,7 @@ export const getModelPriceItems = ( key: 'fixed', label: t('模型价格'), value: priceData.price, - suffix: ` / ${t('次')}`, + suffix: ` / ${priceData.fixedUnit === 'second' ? t('秒') : t('次')}`, }, ].filter((item) => item.value !== null && item.value !== undefined && item.value !== ''); }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 7e4db5f3656..35b2ebf1467 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1057,6 +1057,8 @@ "回调调用者IP": "Callback Caller IP", "回调通知地址": "", "固定价格": "Fixed Price", + "固定价格单位": "Fixed price unit", + "固定价格任务模型的计费单位:per_item 表示按次,per_second 表示按秒。": "Billing unit for fixed-price task models: per_item means per request, per_second means per second.", "固定价格(每次)": "Fixed Price (per use)", "固定价格值": "Fixed Price Value", "图像生成": "Image Generation", @@ -1634,10 +1636,12 @@ "按倍率类型筛选": "Filter by ratio type", "按倍率设置": "Set by ratio", "按次": "Per request", + "按秒": "Per second", "按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "Per request {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}", "按次:{{symbol}}{{price}}": "Per request: {{symbol}}{{price}}", "按次:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}": "Per request: {{symbol}}{{price}} * {{ratioType}}: {{ratio}} = {{symbol}}{{total}}", "按次计费": "Pay per request", + "按秒计费": "Pay per second", "按照如下格式输入:AccessKey|SecretAccessKey|Region": "Enter in the format: AccessKey|SecretAccessKey|Region", "按量计费": "Pay as you go", "按量计费下需要先填写输入价格,才能保存其它价格项。": "For per-token billing, fill in the input price before saving other price fields.", @@ -3309,7 +3313,8 @@ "输入模型倍率": "Enter model ratio", "输入模型名称,例如 gpt-4.1": "Enter a model name, for example gpt-4.1", "输入每次价格": "Enter per-use price", - "输入每次调用价格": "", + "输入每次调用价格": "Enter price per request", + "输入每秒价格": "Enter price per second", "输入端口后回车,如:80 或 8000-8999": "Enter port and press Enter, e.g.: 80 or 8000-8999", "输入系统提示词,用户的系统提示词将优先于此设置": "Enter system prompt, user's system prompt will take priority over this setting", "输入自定义模型名称": "Enter Custom Model Name", @@ -3370,6 +3375,10 @@ "退出": "Quit", "退款": "Refund", "适合 MJ / 任务类等按次收费模型。": "Suitable for MJ and other task-based models billed per request.", + "适合按视频时长计费的任务模型。": "Suitable for task models billed by video duration.", + "任务固定价格单位": "Task fixed price unit", + "为一个 JSON 文本,键为模型名称,值为 per_item 或 per_second,比如 \"seedance-480p-fast-c13\": \"per_second\"": "JSON text where keys are model names and values are per_item or per_second, for example \"seedance-480p-fast-c13\": \"per_second\"", + "$/秒": "$/second", "适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Useful for pricing model variants together, for example syncing the pricing of gpt-5.1 to gpt-5.1-high, gpt-5.1-low, and similar models.", "适用于个人使用的场景,不需要设置模型价格": "Suitable for personal use, no need to set model price.", "适用于为多个用户提供服务的场景": "Suitable for scenarios where multiple users are provided.", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 8c52cdfbba7..3f1d46e5adc 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1032,6 +1032,8 @@ "回调调用者IP": "回调调用者IP", "回调通知地址": "回调通知地址", "固定价格": "固定价格", + "固定价格单位": "固定价格单位", + "固定价格任务模型的计费单位:per_item 表示按次,per_second 表示按秒。": "固定价格任务模型的计费单位:per_item 表示按次,per_second 表示按秒。", "固定价格(每次)": "固定价格(每次)", "固定价格值": "固定价格值", "图像生成": "图像生成", @@ -1596,10 +1598,12 @@ "按倍率类型筛选": "按倍率类型筛选", "按倍率设置": "按倍率设置", "按次": "按次", + "按秒": "按秒", "按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}", "按次:{{symbol}}{{price}}": "按次:{{symbol}}{{price}}", "按次:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}": "按次:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}", "按次计费": "按次计费", + "按秒计费": "按秒计费", "按照如下格式输入:AccessKey|SecretAccessKey|Region": "按照如下格式输入:AccessKey|SecretAccessKey|Region", "按量计费": "按量计费", "按量计费下需要先填写输入价格,才能保存其它价格项。": "按量计费下需要先填写输入价格,才能保存其它价格项。", @@ -3301,6 +3305,7 @@ "输入模型名称,例如 gpt-4.1": "输入模型名称,例如 gpt-4.1", "输入每次价格": "输入每次价格", "输入每次调用价格": "输入每次调用价格", + "输入每秒价格": "输入每秒价格", "输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999", "输入系统提示词,用户的系统提示词将优先于此设置": "输入系统提示词,用户的系统提示词将优先于此设置", "输入自定义模型名称": "输入自定义模型名称", @@ -3361,6 +3366,10 @@ "退出": "退出", "退款": "退款", "适合 MJ / 任务类等按次收费模型。": "适合 MJ / 任务类等按次收费模型。", + "适合按视频时长计费的任务模型。": "适合按视频时长计费的任务模型。", + "任务固定价格单位": "任务固定价格单位", + "为一个 JSON 文本,键为模型名称,值为 per_item 或 per_second,比如 \"seedance-480p-fast-c13\": \"per_second\"": "为一个 JSON 文本,键为模型名称,值为 per_item 或 per_second,比如 \"seedance-480p-fast-c13\": \"per_second\"", + "$/秒": "$/秒", "适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。", "适用于个人使用的场景,不需要设置模型价格": "适用于个人使用的场景,不需要设置模型价格", "适用于为多个用户提供服务的场景": "适用于为多个用户提供服务的场景", diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx index e9be1978554..3d8d8940466 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx @@ -41,6 +41,7 @@ export default function ModelRatioSettings(props) { const [loading, setLoading] = useState(false); const [inputs, setInputs] = useState({ ModelPrice: '', + TaskBillingUnit: '', ModelRatio: '', CacheRatio: '', CreateCacheRatio: '', @@ -163,6 +164,32 @@ export default function ModelRatioSettings(props) { /> + + + verifyJSON(value), + message: '不是合法的 JSON 字符串', + }, + ]} + onChange={(value) => + setInputs({ ...inputs, TaskBillingUnit: value }) + } + /> + + { + const profitPercent = Number(aistarsLabProfitPercent); + if (Number.isNaN(profitPercent) || profitPercent < 0) { + showError(t('利润比例不能小于 0')); + return; + } + + setAistarsLabLoading(true); + try { + const res = await API.post('/api/ratio_sync/aistarslab/sync', { + dry_run: dryRun, + markup_rate: 1 + profitPercent / 100, + }); + + if (!res.data.success) { + showError(res.data.message || t('AistarsLab 即梦同步失败')); + return; + } + + const result = res.data.data; + setAistarsLabResult(result); + + if (dryRun) { + showSuccess(t('AistarsLab 即梦同步预览完成')); + } else { + showSuccess(t('AistarsLab 即梦同步已应用')); + props.refresh(); + } + } catch (error) { + showError(t('请求后端接口失败:') + error.message); + } finally { + setAistarsLabLoading(false); + } + }; + + const confirmAistarsLabSync = () => { + Modal.confirm({ + title: t('确认应用 AistarsLab 即梦同步'), + content: `${t('将更新即梦模型、价格、计费单位和渠道映射。')}${t('当前利润比例')}: ${aistarsLabProfitPercent}%`, + okText: t('确认应用'), + cancelText: t('取消'), + onOk: () => runAistarsLabSync(false), + }); + }; + + const formatAistarsLabValue = (value) => { + if (value === null || value === undefined || value === '') { + return t('未设置'); + } + return String(value); + }; + + const renderAistarsLabHeader = () => ( +
+
+
{t('AistarsLab 即梦模型同步')}
+
+ {t('模型、价格、分辨率和能力配置')} +
+
+
+
+ {t('利润比例')} + { + setAistarsLabProfitPercent(value ?? 0); + setAistarsLabResult(null); + }} + className='w-full sm:w-28' + disabled={aistarsLabLoading} + /> + % +
+ + +
+
+ ); + + const renderAistarsLabResult = () => { + if (!aistarsLabResult) { + return ( + } + darkModeImage={ + + } + description={t('暂无 AistarsLab 即梦同步结果')} + style={{ padding: 30 }} + /> + ); + } + + const rows = []; + const pushModelRows = (type, models, oldValue, newValue) => { + (models || []).forEach((model) => { + rows.push({ + key: `${type}_${model}`, + type, + model, + old: oldValue, + new: newValue, + }); + }); + }; + + pushModelRows( + t('新增模型'), + aistarsLabResult.added_models, + t('未设置'), + t('加入'), + ); + pushModelRows( + t('移除旧模型'), + aistarsLabResult.removed_models, + t('存在'), + t('移除'), + ); + + (aistarsLabResult.price_changes || []).forEach((item) => { + rows.push({ + key: `price_${item.model}`, + type: t('固定价格'), + model: item.model, + old: formatAistarsLabValue(item.old), + new: formatAistarsLabValue(item.new), + }); + }); + + (aistarsLabResult.task_unit_changes || []).forEach((item) => { + rows.push({ + key: `task_unit_${item.model}`, + type: t('计费单位'), + model: item.model, + old: formatAistarsLabValue(item.old), + new: formatAistarsLabValue(item.new), + }); + }); + + (aistarsLabResult.mapping_changes || []).forEach((item) => { + rows.push({ + key: `mapping_${item.model}`, + type: t('渠道映射'), + model: item.model, + old: formatAistarsLabValue(item.old), + new: formatAistarsLabValue(item.new), + }); + }); + + const columns = [ + { + title: t('类型'), + dataIndex: 'type', + render: (text) => ( + + {text} + + ), + }, + { + title: t('模型'), + dataIndex: 'model', + render: (text) => {text}, + }, + { + title: t('当前值'), + dataIndex: 'old', + render: (text) => {text}, + }, + { + title: t('同步后'), + dataIndex: 'new', + render: (text) => {text}, + }, + ]; + + return ( +
+
+ + {t('模型总数')}: {aistarsLabResult.total_models || 0} + + + {t('新增')}: {aistarsLabResult.added_models?.length || 0} + + + {t('移除')}: {aistarsLabResult.removed_models?.length || 0} + + + {t('价格')}: {aistarsLabResult.price_changes?.length || 0} + + + {t('计费单位')}: {aistarsLabResult.task_unit_changes?.length || 0} + + + {t('映射')}: {aistarsLabResult.mapping_changes?.length || 0} + + + {t('利润比例')}:{' '} + {Number.isFinite(aistarsLabResult.markup_rate) + ? `${Math.round((aistarsLabResult.markup_rate - 1) * 10000) / 100}%` + : '-'} + + + {aistarsLabResult.dry_run ? t('预览') : t('已应用')} + +
+ + } + darkModeImage={ + + } + description={t('没有需要变更的项目')} + style={{ padding: 30 }} + /> + } + /> + + ); + }; + const renderHeader = () => (
@@ -853,6 +1114,10 @@ export default function UpstreamRatioSync(props) { return ( <> + + {renderAistarsLabResult()} + + {renderDifferenceTable()} diff --git a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx b/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx index 5028a3ffdba..f94677d1aac 100644 --- a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx +++ b/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx @@ -123,6 +123,7 @@ export default function ModelPricingEditor({ handleOptionalFieldToggle, handleNumericFieldChange, handleBillingModeChange, + handleTaskBillingUnitChange, handleSubmit, addModel, deleteModel, @@ -175,9 +176,19 @@ export default function ModelPricingEditor({ dataIndex: 'billingMode', key: 'billingMode', render: (_, record) => ( - + {record.billingMode === 'per-request' - ? t('按次计费') + ? record.taskBillingUnit === 'per_second' + ? t('按秒计费') + : t('按次计费') : t('按量计费')} ), @@ -355,7 +366,9 @@ export default function ModelPricingEditor({ selectedModel ? ( {selectedModel.billingMode === 'per-request' - ? t('按次计费') + ? selectedModel.taskBillingUnit === 'per_second' + ? t('按秒计费') + : t('按次计费') : t('按量计费')} ) : null @@ -407,14 +420,45 @@ export default function ModelPricingEditor({ ) : null} {selectedModel.billingMode === 'per-request' ? ( - handleNumericFieldChange('fixedPrice', value)} - extraText={t('适合 MJ / 任务类等按次收费模型。')} - /> + <> +
+
+ {t('固定价格单位')} +
+ + handleTaskBillingUnitChange(event.target.value) + } + > + {t('按次计费')} + {t('按秒计费')} + +
+ + handleNumericFieldChange('fixedPrice', value) + } + extraText={ + selectedModel.taskBillingUnit === 'per_second' + ? t('适合按视频时长计费的任务模型。') + : t('适合 MJ / 任务类等按次收费模型。') + } + /> + ) : ( <> { sourceMaps.AudioCompletionRatio[name], ); const fixedPrice = toNumericString(sourceMaps.ModelPrice[name]); + const taskBillingUnit = + sourceMaps.TaskBillingUnit[name] === 'per_second' ? 'per_second' : 'per_item'; const inputPrice = ratioToBasePrice(modelRatio); const inputPriceNumber = toNumberOrNull(inputPrice); const audioInputPrice = @@ -122,6 +125,7 @@ const buildModelState = (name, sourceMaps) => { ...EMPTY_MODEL, name, billingMode: hasValue(fixedPrice) ? 'per-request' : 'per-token', + taskBillingUnit, fixedPrice, inputPrice, completionRatioLocked: completionRatioMeta.locked, @@ -245,7 +249,8 @@ export const getModelWarnings = (model, t) => { export const buildSummaryText = (model, t) => { if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) { - return `${t('按次')} $${model.fixedPrice} / ${t('次')}`; + const unit = model.taskBillingUnit === 'per_second' ? t('秒') : t('次'); + return `${model.taskBillingUnit === 'per_second' ? t('按秒') : t('按次')} $${model.fixedPrice} / ${unit}`; } if (hasValue(model.inputPrice)) { @@ -278,6 +283,7 @@ export const buildOptionalFieldToggles = (model) => ({ const serializeModel = (model, t) => { const result = { ModelPrice: null, + TaskBillingUnit: null, ModelRatio: null, CompletionRatio: null, CacheRatio: null, @@ -291,6 +297,8 @@ const serializeModel = (model, t) => { if (hasValue(model.fixedPrice)) { result.ModelPrice = toNormalizedNumber(model.fixedPrice); } + result.TaskBillingUnit = + model.taskBillingUnit === 'per_second' ? 'per_second' : 'per_item'; return result; } @@ -403,6 +411,14 @@ export const buildPreviewRows = (model, t) => { label: 'ModelPrice', value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'), }, + { + key: 'TaskBillingUnit', + label: 'TaskBillingUnit', + value: + model.taskBillingUnit === 'per_second' + ? 'per_second' + : 'per_item', + }, ]; } @@ -552,6 +568,7 @@ export function useModelPricingEditorState({ ImageRatio: parseOptionJSON(options.ImageRatio), AudioRatio: parseOptionJSON(options.AudioRatio), AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio), + TaskBillingUnit: parseOptionJSON(options.TaskBillingUnit), }; const names = new Set([ @@ -565,6 +582,7 @@ export function useModelPricingEditorState({ ...Object.keys(sourceMaps.ImageRatio), ...Object.keys(sourceMaps.AudioRatio), ...Object.keys(sourceMaps.AudioCompletionRatio), + ...Object.keys(sourceMaps.TaskBillingUnit), ]); const nextModels = Array.from(names) @@ -782,6 +800,14 @@ export function useModelPricingEditorState({ })); }; + const handleTaskBillingUnitChange = (value) => { + if (!selectedModel) return; + upsertModel(selectedModel.name, (model) => ({ + ...model, + taskBillingUnit: value === 'per_second' ? 'per_second' : 'per_item', + })); + }; + const addModel = (modelName) => { const trimmedName = modelName.trim(); if (!trimmedName) { @@ -846,6 +872,7 @@ export function useModelPricingEditorState({ const nextModel = { ...model, billingMode: selectedModel.billingMode, + taskBillingUnit: selectedModel.taskBillingUnit, fixedPrice: selectedModel.fixedPrice, inputPrice: selectedModel.inputPrice, completionPrice: selectedModel.completionPrice, @@ -913,6 +940,7 @@ export function useModelPricingEditorState({ ImageRatio: {}, AudioRatio: {}, AudioCompletionRatio: {}, + TaskBillingUnit: {}, }; for (const model of models) { @@ -970,6 +998,7 @@ export function useModelPricingEditorState({ handleOptionalFieldToggle, handleNumericFieldChange, handleBillingModeChange, + handleTaskBillingUnitChange, handleSubmit, addModel, deleteModel,