From 0bad71ec7287cddaa99f30196039d718d2c58c58 Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Mon, 29 Jun 2026 18:41:08 +0800 Subject: [PATCH 01/24] fix video task successful upstream result parsing --- service/task_billing_test.go | 56 ++++++++++++++++++++++++++ service/task_polling.go | 76 ++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 39cb8f1da1aa..eb6b2a8444cd 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" @@ -714,3 +715,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 dc85e579e8cc..d7187e030cbf 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 { From 6cff1be95bfe375b3b2c295fbbb73a57346f74dc Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Mon, 29 Jun 2026 19:46:27 +0800 Subject: [PATCH 02/24] fix task per-call billing ratio recalculation --- relay/relay_task.go | 15 ++++++++++---- relay/relay_task_test.go | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 relay/relay_task_test.go diff --git a/relay/relay_task.go b/relay/relay_task.go index 098e23828b6c..cd41bcd442c5 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -194,7 +194,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } // 6. 将 OtherRatios 应用到基础额度 - if !common.StringsContains(constant.TaskPricePatches, modelName) { + if !isTaskPerCallModel(modelName) { for _, ra := range info.PriceData.OtherRatios { if ra != 1.0 { info.PriceData.Quota = int(float64(info.PriceData.Quota) * ra) @@ -243,8 +243,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, isTaskPerCallModel(modelName)) info.PriceData.OtherRatios = adjustedRatios info.PriceData.Quota = finalQuota } @@ -259,7 +259,10 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe // 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 恢复基础额度 @@ -278,6 +281,10 @@ func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float6 return int(result) } +func isTaskPerCallModel(modelName string) bool { + return common.StringsContains(constant.TaskPricePatches, modelName) +} + var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){ relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder, relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder, diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go new file mode 100644 index 000000000000..5f7e06f97615 --- /dev/null +++ b/relay/relay_task_test.go @@ -0,0 +1,45 @@ +package relay + +import ( + "testing" + + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/stretchr/testify/assert" +) + +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 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) +} From 275c96a5f03fe3c88df07429cafd1eb3a82ffe1c Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Mon, 29 Jun 2026 20:15:31 +0800 Subject: [PATCH 03/24] show per-second task pricing in marketplace --- model/pricing.go | 3 +++ .../table/model-pricing/filter/PricingQuotaTypes.jsx | 1 + .../model-pricing/modal/components/ModelPricingTable.jsx | 5 ++++- .../table/model-pricing/view/card/PricingCardView.jsx | 6 ++++++ .../table/model-pricing/view/table/PricingTableColumns.jsx | 6 ++++++ web/src/helpers/utils.jsx | 7 ++++--- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh-CN.json | 1 + 8 files changed, 26 insertions(+), 4 deletions(-) diff --git a/model/pricing.go b/model/pricing.go index 54ae98451337..4e019b8e1659 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -296,6 +296,9 @@ func updatePricing() { if findPrice { pricing.ModelPrice = modelPrice pricing.QuotaType = 1 + if strings.Contains(pricing.Tags, "按秒") { + pricing.QuotaType = 2 + } } else { modelRatio, _, _ := ratio_setting.GetModelRatio(model) pricing.ModelRatio = modelRatio diff --git a/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx b/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx index b4bafbb10291..7823f61b7449 100644 --- a/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx +++ b/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx @@ -43,6 +43,7 @@ const PricingQuotaTypes = ({ { value: 'all', label: t('全部类型'), tagCount: qtyCount('all') }, { value: 0, label: t('按量计费'), tagCount: qtyCount(0) }, { value: 1, label: t('按次计费'), tagCount: qtyCount(1) }, + { value: 2, label: t('按秒计费'), tagCount: qtyCount(2) }, ]; return ( diff --git a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx index b2064609464e..7374d96df491 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx @@ -75,7 +75,9 @@ const ModelPricingTable = ({ ? t('按量计费') : modelData?.quota_type === 1 ? t('按次计费') - : '-', + : modelData?.quota_type === 2 + ? t('按秒计费') + : '-', priceItems: getModelPriceItems(priceData, t, siteDisplayType), }; }); @@ -115,6 +117,7 @@ const ModelPricingTable = ({ let color = 'white'; if (text === t('按量计费')) color = 'violet'; else if (text === t('按次计费')) color = 'teal'; + else if (text === t('按秒计费')) color = 'cyan'; 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 477da259d7a5..60171938a517 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 8b61d5c80cc7..1b9850d4a93d 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 435a11ed3d18..ed427b550679 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 7e4db5f36563..036ac1e9df47 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1638,6 +1638,7 @@ "按次:{{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.", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 8c52cdfbba75..8d208fa6c6fd 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1600,6 +1600,7 @@ "按次:{{symbol}}{{price}}": "按次:{{symbol}}{{price}}", "按次:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}": "按次:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}", "按次计费": "按次计费", + "按秒计费": "按秒计费", "按照如下格式输入:AccessKey|SecretAccessKey|Region": "按照如下格式输入:AccessKey|SecretAccessKey|Region", "按量计费": "按量计费", "按量计费下需要先填写输入价格,才能保存其它价格项。": "按量计费下需要先填写输入价格,才能保存其它价格项。", From bf45c7785322032a4baba7afa6bcd5ec83ae66c6 Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Mon, 29 Jun 2026 20:38:09 +0800 Subject: [PATCH 04/24] document seedance video aliases --- docs/seedance-video-integration.md | 185 +++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/seedance-video-integration.md diff --git a/docs/seedance-video-integration.md b/docs/seedance-video-integration.md new file mode 100644 index 000000000000..498d0f3f67e5 --- /dev/null +++ b/docs/seedance-video-integration.md @@ -0,0 +1,185 @@ +# 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. + +## 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. From 37a6e5384fdf13c71d81828626d408aee0e102c5 Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Tue, 30 Jun 2026 11:33:39 +0800 Subject: [PATCH 05/24] document grok video api --- docs/grok-video-api.md | 239 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/grok-video-api.md diff --git a/docs/grok-video-api.md b/docs/grok-video-api.md new file mode 100644 index 000000000000..08ad1c6ad21b --- /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 +``` + From 4529a18e2c986adda54f33f969f8ecbb6c6a6be8 Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Tue, 30 Jun 2026 11:42:57 +0800 Subject: [PATCH 06/24] add Chinese video integration docs --- docs/grok-video-api.zh-CN.md | 239 +++++++++++++++++++++++ docs/seedance-video-integration.zh-CN.md | 185 ++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 docs/grok-video-api.zh-CN.md create mode 100644 docs/seedance-video-integration.zh-CN.md diff --git a/docs/grok-video-api.zh-CN.md b/docs/grok-video-api.zh-CN.md new file mode 100644 index 000000000000..80635207265a --- /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.zh-CN.md b/docs/seedance-video-integration.zh-CN.md new file mode 100644 index 000000000000..855633954c33 --- /dev/null +++ b/docs/seedance-video-integration.zh-CN.md @@ -0,0 +1,185 @@ +# 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 的上游模型自动同步应保持关闭,否则原始上游模型可能重新出现,或者对外别名会被误判为上游已删除模型。 + +## 验证命令 + +检查原始上游 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` 对外别名,不出现原始上游模型名。 + From 2b5c72e53e5a9b608f6dcf6957589aef415071b1 Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Wed, 1 Jul 2026 11:51:12 +0800 Subject: [PATCH 07/24] fix seedance per-second pricing display --- model/pricing.go | 15 +++++++++++---- model/pricing_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 model/pricing_test.go diff --git a/model/pricing.go b/model/pricing.go index 4e019b8e1659..8ded10801b73 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -295,10 +295,7 @@ func updatePricing() { modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) if findPrice { pricing.ModelPrice = modelPrice - pricing.QuotaType = 1 - if strings.Contains(pricing.Tags, "按秒") { - pricing.QuotaType = 2 - } + pricing.QuotaType = fixedPriceQuotaType(model, pricing.Tags) } else { modelRatio, _, _ := ratio_setting.GetModelRatio(model) pricing.ModelRatio = modelRatio @@ -343,6 +340,16 @@ func updatePricing() { lastGetPricingTime = time.Now() } +func fixedPriceQuotaType(modelName, tags string) int { + if strings.Contains(tags, "按秒") { + return 2 + } + if strings.HasPrefix(modelName, "seedance-") && !common.StringsContains(constant.TaskPricePatches, modelName) { + 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 000000000000..1fe0e293863c --- /dev/null +++ b/model/pricing_test.go @@ -0,0 +1,25 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + "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"} + + assert.Equal(t, 1, fixedPriceQuotaType("seedance-720p-c37", "video,seedance,??")) + assert.Equal(t, 2, fixedPriceQuotaType("seedance-480p-fast-c13", "video,seedance,??")) +} + +func TestFixedPriceQuotaTypeTagsStillSupportPerSecond(t *testing.T) { + assert.Equal(t, 2, fixedPriceQuotaType("custom-video-model", "video,按秒")) + assert.Equal(t, 1, fixedPriceQuotaType("custom-video-model", "video")) +} From 8f8d4cce21ec8b136d42ec8c50a5b57c509c8a6a Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Wed, 1 Jul 2026 12:58:12 +0800 Subject: [PATCH 08/24] add configurable task billing units --- controller/relay.go | 3 +- model/option.go | 9 ++ model/pricing.go | 7 +- model/pricing_test.go | 26 +++++ relay/relay_task.go | 26 +++-- relay/relay_task_test.go | 54 +++++++++ service/task_billing.go | 4 +- setting/ratio_setting/exposed_cache.go | 1 + setting/ratio_setting/task_billing_unit.go | 104 ++++++++++++++++++ .../ratio_setting/task_billing_unit_test.go | 56 ++++++++++ web/src/i18n/locales/en.json | 10 +- web/src/i18n/locales/zh-CN.json | 8 ++ .../Setting/Ratio/ModelRatioSettings.jsx | 27 +++++ .../Ratio/components/ModelPricingEditor.jsx | 66 +++++++++-- .../Ratio/hooks/useModelPricingEditorState.js | 31 +++++- 15 files changed, 401 insertions(+), 31 deletions(-) create mode 100644 setting/ratio_setting/task_billing_unit.go create mode 100644 setting/ratio_setting/task_billing_unit_test.go diff --git a/controller/relay.go b/controller/relay.go index c97ab45b4ac4..a59b3abd66ec 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 diff --git a/model/option.go b/model/option.go index 37fb6cf5bdc6..4add814d7c1a 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 8ded10801b73..84416aa5550d 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -341,10 +341,13 @@ func updatePricing() { } func fixedPriceQuotaType(modelName, tags string) int { - if strings.Contains(tags, "按秒") { + if ratio_setting.IsTaskPerSecondBilling(modelName) { return 2 } - if strings.HasPrefix(modelName, "seedance-") && !common.StringsContains(constant.TaskPricePatches, modelName) { + if ratio_setting.IsTaskPerItemBilling(modelName) { + return 1 + } + if strings.Contains(tags, "按秒") { return 2 } return 1 diff --git a/model/pricing_test.go b/model/pricing_test.go index 1fe0e293863c..5ecae0258735 100644 --- a/model/pricing_test.go +++ b/model/pricing_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/stretchr/testify/assert" ) @@ -14,12 +15,37 @@ func TestFixedPriceQuotaTypeSeedanceUsesTaskPricePatch(t *testing.T) { }) 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/relay/relay_task.go b/relay/relay_task.go index cd41bcd442c5..6a69dd1ab5c5 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 !isTaskPerCallModel(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 { @@ -244,7 +239,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe finalQuota := info.PriceData.Quota if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData); len(adjustedRatios) > 0 { // 基于调整后的 ratios 重新计算 quota;按次模型仅记录倍率,不参与扣费。 - finalQuota = recalcQuotaFromRatios(info, adjustedRatios, isTaskPerCallModel(modelName)) + finalQuota = recalcQuotaFromRatios(info, adjustedRatios, ratio_setting.IsTaskPerItemBilling(modelName)) info.PriceData.OtherRatios = adjustedRatios info.PriceData.Quota = finalQuota } @@ -257,6 +252,17 @@ 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, perCall bool) int { @@ -281,10 +287,6 @@ func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float6 return int(result) } -func isTaskPerCallModel(modelName string) bool { - return common.StringsContains(constant.TaskPricePatches, modelName) -} - var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){ relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder, relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder, diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go index 5f7e06f97615..057e69697c02 100644 --- a/relay/relay_task_test.go +++ b/relay/relay_task_test.go @@ -3,11 +3,54 @@ 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{ @@ -26,6 +69,17 @@ func TestRecalcQuotaFromRatiosPerCallKeepsBaseQuota(t *testing.T) { 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{ diff --git a/service/task_billing.go b/service/task_billing.go index 6cf7a965c8eb..5abfcc54bd48 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/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go index c88216fcb015..902ec89a4af5 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/task_billing_unit.go b/setting/ratio_setting/task_billing_unit.go new file mode 100644 index 000000000000..df157d20d8fe --- /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 000000000000..e972c39020fe --- /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/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 036ac1e9df47..35b2ebf14672 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,6 +1636,7 @@ "按倍率类型筛选": "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}}", @@ -3310,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", @@ -3371,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 8d208fa6c6fd..3f1d46e5adcd 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,6 +1598,7 @@ "按倍率类型筛选": "按倍率类型筛选", "按倍率设置": "按倍率设置", "按次": "按次", + "按秒": "按秒", "按次 {{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}}", @@ -3302,6 +3305,7 @@ "输入模型名称,例如 gpt-4.1": "输入模型名称,例如 gpt-4.1", "输入每次价格": "输入每次价格", "输入每次调用价格": "输入每次调用价格", + "输入每秒价格": "输入每秒价格", "输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999", "输入系统提示词,用户的系统提示词将优先于此设置": "输入系统提示词,用户的系统提示词将优先于此设置", "输入自定义模型名称": "输入自定义模型名称", @@ -3362,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 e9be19785547..3d8d89404662 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 }) + } + /> + + ( - + {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, From 03afab9f27bc4fa2f8cfa5113ae772ebe56a6c1a Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Wed, 1 Jul 2026 13:07:38 +0800 Subject: [PATCH 09/24] add GHCR image workflow --- .github/workflows/carmin-ghcr-image.yml | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/carmin-ghcr-image.yml diff --git a/.github/workflows/carmin-ghcr-image.yml b/.github/workflows/carmin-ghcr-image.yml new file mode 100644 index 000000000000..0b977fbb8d5a --- /dev/null +++ b/.github/workflows/carmin-ghcr-image.yml @@ -0,0 +1,65 @@ +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-latest + 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 QEMU + uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3 + + - 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 From fa0af9e8c7ba098e1b96bf9d254d8d38a4b09bde Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Wed, 1 Jul 2026 13:18:40 +0800 Subject: [PATCH 10/24] use native arm runner for GHCR image --- .github/workflows/carmin-ghcr-image.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/carmin-ghcr-image.yml b/.github/workflows/carmin-ghcr-image.yml index 0b977fbb8d5a..7a4a4e0b72c1 100644 --- a/.github/workflows/carmin-ghcr-image.yml +++ b/.github/workflows/carmin-ghcr-image.yml @@ -12,7 +12,7 @@ env: jobs: build: name: Build and push Docker image - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm permissions: contents: read packages: write @@ -30,9 +30,6 @@ jobs: echo "$VERSION" > VERSION echo "value=$VERSION" >> "$GITHUB_OUTPUT" - - name: Set up QEMU - uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 From 3372d3535d39b9856de3dad86e578bd3946510ec Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Sat, 4 Jul 2026 00:38:26 +0800 Subject: [PATCH 11/24] add aistarslab config sync --- controller/aistarslab_sync.go | 39 + docs/seedance-video-integration.md | 36 + docs/seedance-video-integration.zh-CN.md | 37 +- main.go | 3 + router/api-router.go | 1 + service/aistarslab_config_sync.go | 692 ++++++++++++++++++ service/aistarslab_config_sync_test.go | 131 ++++ .../pages/Setting/Ratio/UpstreamRatioSync.jsx | 234 ++++++ 8 files changed, 1172 insertions(+), 1 deletion(-) create mode 100644 controller/aistarslab_sync.go create mode 100644 service/aistarslab_config_sync.go create mode 100644 service/aistarslab_config_sync_test.go diff --git a/controller/aistarslab_sync.go b/controller/aistarslab_sync.go new file mode 100644 index 000000000000..bf9e5b657fac --- /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/docs/seedance-video-integration.md b/docs/seedance-video-integration.md index 498d0f3f67e5..4832c93f78b6 100644 --- a/docs/seedance-video-integration.md +++ b/docs/seedance-video-integration.md @@ -143,6 +143,42 @@ Important settings: - 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: diff --git a/docs/seedance-video-integration.zh-CN.md b/docs/seedance-video-integration.zh-CN.md index 855633954c33..20c66ef64db5 100644 --- a/docs/seedance-video-integration.zh-CN.md +++ b/docs/seedance-video-integration.zh-CN.md @@ -142,6 +142,42 @@ seedance-720p-4img-c18 - 原始上游 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 模型没有暴露: @@ -182,4 +218,3 @@ curl -sS 'https://token.mewinyou.shop/v1/models' \ ``` 期望结果:只出现 `seedance-...-cXX` 对外别名,不出现原始上游模型名。 - diff --git a/main.go b/main.go index dbbf44a1826b..d8587e70e6bd 100644 --- a/main.go +++ b/main.go @@ -112,6 +112,9 @@ func main() { // Subscription quota reset task (daily/weekly/monthly/custom) service.StartSubscriptionQuotaResetTask() + // 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/router/api-router.go b/router/api-router.go index 83f5e4ae9d92..10d19e6e311c 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()) diff --git a/service/aistarslab_config_sync.go b/service/aistarslab_config_sync.go new file mode 100644 index 000000000000..9c0c6b2c2295 --- /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 000000000000..798a6523822c --- /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/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx index 20ea45e9ebd0..733c5001fec4 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx @@ -103,6 +103,8 @@ export default function UpstreamRatioSync(props) { const [modalVisible, setModalVisible] = useState(false); const [loading, setLoading] = useState(false); const [syncLoading, setSyncLoading] = useState(false); + const [aistarsLabLoading, setAistarsLabLoading] = useState(false); + const [aistarsLabResult, setAistarsLabResult] = useState(null); const isMobile = useIsMobile(); // 渠道选择相关 @@ -444,6 +446,234 @@ export default function UpstreamRatioSync(props) { return dataSource.slice(startIndex, endIndex); }; + const runAistarsLabSync = async (dryRun = true) => { + setAistarsLabLoading(true); + try { + const res = await API.post('/api/ratio_sync/aistarslab/sync', { + dry_run: dryRun, + }); + + 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('将更新即梦模型、价格、计费单位和渠道映射。'), + 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('模型、价格、分辨率和能力配置')} +
+
+
+ + +
+
+ ); + + 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} + + + {aistarsLabResult.dry_run ? t('预览') : t('已应用')} + +
+ + } + darkModeImage={ + + } + description={t('没有需要变更的项目')} + style={{ padding: 30 }} + /> + } + /> + + ); + }; + const renderHeader = () => (
@@ -853,6 +1083,10 @@ export default function UpstreamRatioSync(props) { return ( <> + + {renderAistarsLabResult()} + + {renderDifferenceTable()} From bbbf28815df89ca3048be2a5797ced93c8b4b36b Mon Sep 17 00:00:00 2001 From: "649985538@qq.com" <649985538@qq.com> Date: Sat, 4 Jul 2026 00:50:12 +0800 Subject: [PATCH 12/24] add aistarslab profit rate control --- .../pages/Setting/Ratio/UpstreamRatioSync.jsx | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx index 733c5001fec4..b13b04b82228 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx @@ -26,6 +26,7 @@ import { Checkbox, Form, Input, + InputNumber, Tooltip, Select, Modal, @@ -105,6 +106,7 @@ export default function UpstreamRatioSync(props) { const [syncLoading, setSyncLoading] = useState(false); const [aistarsLabLoading, setAistarsLabLoading] = useState(false); const [aistarsLabResult, setAistarsLabResult] = useState(null); + const [aistarsLabProfitPercent, setAistarsLabProfitPercent] = useState(30); const isMobile = useIsMobile(); // 渠道选择相关 @@ -447,10 +449,17 @@ export default function UpstreamRatioSync(props) { }; const runAistarsLabSync = async (dryRun = true) => { + 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) { @@ -477,7 +486,7 @@ export default function UpstreamRatioSync(props) { const confirmAistarsLabSync = () => { Modal.confirm({ title: t('确认应用 AistarsLab 即梦同步'), - content: t('将更新即梦模型、价格、计费单位和渠道映射。'), + content: `${t('将更新即梦模型、价格、计费单位和渠道映射。')}${t('当前利润比例')}: ${aistarsLabProfitPercent}%`, okText: t('确认应用'), cancelText: t('取消'), onOk: () => runAistarsLabSync(false), @@ -500,6 +509,22 @@ export default function UpstreamRatioSync(props) {
+
+ {t('利润比例')} + { + setAistarsLabProfitPercent(value ?? 0); + setAistarsLabResult(null); + }} + className='w-full sm:w-28' + disabled={aistarsLabLoading} + /> + % +