Sfoglia il codice sorgente

feat(pricing): support usage billing and endpoint fixes

Add KlingAiping usage-token settlement from upstream final_unit_deduction and duration fallback handling.

Normalize model endpoint config persistence and convert model pricing values between stored USD and display currency in the editor.

Co-Authored-By: Codex <noreply@anthropic.com>
master
fengsilin 1 giorno fa
parent
commit
3fbf48313a
17 ha cambiato i file con 557 aggiunte e 13 eliminazioni
  1. +27
    -1
      controller/kling_aiping_native.go
  2. +16
    -0
      controller/kling_aiping_native_test.go
  3. +8
    -0
      controller/model_meta.go
  4. +89
    -0
      controller/model_meta_test.go
  5. +89
    -0
      docs/seedance-aiping-cn-tasks.md
  6. +32
    -2
      relay/channel/task/kling/aiping/adaptor.go
  7. +47
    -0
      relay/channel/task/kling/aiping/adaptor_test.go
  8. +13
    -0
      relay/helper/matrix_usage_capability.go
  9. +19
    -0
      relay/helper/matrix_usage_capability_test.go
  10. +40
    -0
      service/task_billing_test.go
  11. +4
    -1
      setting/ratio_setting/model_pricing.go
  12. +20
    -0
      setting/ratio_setting/model_pricing_test.go
  13. +18
    -0
      web/src/components/table/models/modals/modelEndpoints.utils.js
  14. +19
    -0
      web/src/components/table/models/modals/modelEndpoints.utils.test.js
  15. +41
    -6
      web/src/pages/Setting/Operation/ModelPricing/Editor.jsx
  16. +42
    -0
      web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js
  17. +33
    -3
      web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js

+ 27
- 1
controller/kling_aiping_native.go Vedi File

@@ -80,11 +80,37 @@ func configureKlingAipingTaskRelayInfo(c *gin.Context, relayInfo *relaycommon.Re
relaycommon.StoreTaskRequest(c, relayInfo, route.Action, relaycommon.TaskSubmitReq{
Model: modelName,
Prompt: stringFromMap(payload, "prompt"),
Seconds: fmt.Sprint(payload["seconds"]),
Duration: durationFromMap(payload),
Metadata: payload,
})
}

func durationFromMap(m map[string]any) int {
for _, key := range []string{"duration", "seconds"} {
if duration, ok := intFromMapValue(m[key]); ok {
return duration
}
}
return 0
}

func intFromMapValue(value any) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case int64:
return int(v), true
case float64:
return int(v), true
case string:
duration, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil {
return duration, true
}
}
return 0, false
}

func KlingAipingNativeTaskFetch(c *gin.Context) {
route, ok := klingaiping.FindRoute(c.Request.Method, c.FullPath(), klingaiping.RouteKindFetch)
if !ok {


+ 16
- 0
controller/kling_aiping_native_test.go Vedi File

@@ -83,6 +83,22 @@ func TestConfigureKlingAipingTaskRelayInfoForcesChannelSelection(t *testing.T) {
require.Nil(t, info.LockedChannel)
}

func TestConfigureKlingAipingTaskRelayInfoStoresDuration(t *testing.T) {
c := newControllerJSONContext(t, "/v1/videos/text2video", `{"model_name":"Kling-V2.6","prompt":"prompt","duration":5}`)
payload, err := readJSONPayload(c)
require.NoError(t, err)
route, ok := klingaiping.FindRoute(http.MethodPost, "/v1/videos/text2video", klingaiping.RouteKindSubmit)
require.True(t, ok)
info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}

configureKlingAipingTaskRelayInfo(c, info, route, payload)

stored, err := relaycommon.GetTaskRequest(c)
require.NoError(t, err)
require.Equal(t, 5, stored.Duration)
require.Empty(t, stored.Seconds)
}

func TestRequiredTaskChannelTypeOnlyMatchesKlingAipingNativePaths(t *testing.T) {
c := newControllerJSONContext(t, "/v1/videos/text2video", `{}`)
c.Request.URL.Path = "/v1/videos/text2video"


+ 8
- 0
controller/model_meta.go Vedi File

@@ -97,6 +97,10 @@ func CreateModelMeta(c *gin.Context) {
return
}

if strings.TrimSpace(m.Endpoints) == "" {
m.Endpoints = `["openai"]`
}

if err := m.Insert(); err != nil {
common.ApiError(c, err)
return
@@ -135,6 +139,10 @@ func UpdateModelMeta(c *gin.Context) {
return
}

if strings.TrimSpace(m.Endpoints) == "" {
m.Endpoints = `["openai"]`
}

if err := m.Update(); err != nil {
common.ApiError(c, err)
return


+ 89
- 0
controller/model_meta_test.go Vedi File

@@ -0,0 +1,89 @@
package controller

import (
"bytes"
"net/http"
"net/http/httptest"
"strconv"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupModelMetaTestDB(t *testing.T) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Model{}))

origDB := model.DB
model.DB = db
t.Cleanup(func() {
model.RefreshPricing()
model.DB = origDB
})
}

func TestCreateModelMetaDefaultsEmptyEndpointsToOpenAI(t *testing.T) {
setupModelMetaTestDB(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/api/models/", CreateModelMeta)

req := httptest.NewRequest(
http.MethodPost,
"/api/models/",
bytes.NewBufferString(`{"model_name":"custom-chat-model"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

r.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, common.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp["success"].(bool))

var saved model.Model
require.NoError(t, model.DB.Where("model_name = ?", "custom-chat-model").First(&saved).Error)
require.Equal(t, `["openai"]`, saved.Endpoints)
}

func TestUpdateModelMetaDefaultsEmptyEndpointsToOpenAI(t *testing.T) {
setupModelMetaTestDB(t)
gin.SetMode(gin.TestMode)
existing := &model.Model{
ModelName: "custom-chat-model",
Endpoints: `{"anthropic":{"path":"/v1/messages","method":"POST"}}`,
Status: 1,
}
require.NoError(t, existing.Insert())

r := gin.New()
r.PUT("/api/models/", UpdateModelMeta)

req := httptest.NewRequest(
http.MethodPut,
"/api/models/",
bytes.NewBufferString(`{"id":`+strconv.Itoa(existing.Id)+`,"model_name":"custom-chat-model","endpoints":"","status":1}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

r.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, common.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp["success"].(bool))

var saved model.Model
require.NoError(t, model.DB.First(&saved, existing.Id).Error)
require.Equal(t, `["openai"]`, saved.Endpoints)
}

+ 89
- 0
docs/seedance-aiping-cn-tasks.md Vedi File

@@ -0,0 +1,89 @@
# CN 线上 Seedance 任务 — Aiping 上游返回数据

> 数据来源:CN 服务器 `root@123.57.74.135`,MySQL 容器 `ov-mysql`,数据库 `new-api`,表 `tasks`
> 查询时间:2026-07-06

## 任务完整参数

| # | task_id | 模型 | aiping_id | 上游 task_id | Total Tokens | 分辨率 | 时长 | 比例 | FPS | Seed | Audio | Tier |
|---|---------|------|-----------|-------------|:-----------:|:-----:|:---:|:----:|:---:|:----:|:-----:|:----:|
| 1 | `task_iIyaivFD1nn0h3pO9BvWVb5Z3csNzcj4` | Seedance-2.0 | `ee43bb3f-af2f-4c22-a7e2-cfbf6ff2da01` | `cgt-20260623152942-bvb94` | 108,900 | 720p | 5s | 16:9 | 24 | 23651 | ✅ | default |
| 2 | `task_8jNrKcgTSKZPjrs2uLhnYNk7WhHxnQW8` | Seedance-2.0 | `daf418a3-32b6-4083-afe9-4dd958396c59` | `cgt-20260623152943-vf5vd` | **245,025** | **1080p** | 5s | 16:9 | 24 | 36954 | ✅ | default |
| 3 | `task_2a0nxUWALYZuRndTcAQ9XS2alTQ2AeFX` | Seedance-2.0 | `ee91adc3-445f-4359-b5c7-7cd6b482e88d` | `cgt-20260623152943-f4nqx` | **50,638** | **480p** | 5s | 16:9 | 24 | 8813 | ✅ | default |
| 4 | `task_b1LIHZ6qLjkzZYsxFLcTbVA1rPWKgqDe` | Seedance-2.0-fast | `19cd2e2f-b1fd-4b6d-b2b8-cc21757bb61c` | `cgt-20260623152944-jtbq5` | 108,900 | 720p | 5s | **9:16** | 24 | 6689 | ✅ | default |
| 5 | `task_lonxGyh9CCLzbr6PU4XTdC3mYjq3aErx` | Seedance-2.0 | `947033ed-0b6d-4eb3-a48e-f80648e98c37` | `cgt-20260623153543-glrq7` | 108,900 | 720p | 5s | 16:9 | 24 | 65323 | ✅ | default |
| 6 | `task_6IpuBtvZuaLBsHwQcR1tGCncw5rHu2Et` | Seedance-2.0 | `1daf3f8d-d718-4990-bc19-f917457cfdeb` | `cgt-20260623153741-4h5r5` | 108,900 | 720p | 5s | 16:9 | 24 | 4233 | ✅ | default |
| 7 | `task_paCqHNnxqjl5fLlHjptD8cjS1TXNrE7K` | Seedance-2.0 | `b7ee658f-acac-433d-897d-5c9ed55ef402` | `cgt-20260624142609-vnwrl` | 108,900 | 720p | 5s | 16:9 | 24 | 78491 | ❌ | default |
| 8 | `task_UGMvicub7MIJSGHIYJ38o2T2Z8ULPyIJ` | Seedance-2.0 | `3828ab25-4ca1-4dd5-bc22-58bbfd723ae4` | `cgt-20260624142609-wt27b` | 108,900 | 720p | 5s | 16:9 | 24 | 62735 | ✅ | default |
| 9 | `task_jLgr8WpFOS1R8vxXPgYPQAZjMYypG683` | Seedance-2.0-fast | `fda11914-bbce-4937-b601-5a5ac082c05d` | `cgt-20260624142610-j98vj` | 108,900 | 720p | 5s | 16:9 | 24 | 25865 | ❌ | default |
| 10 | `task_XvJL39JVdEFT28DMcNyAUWoKzOGOtohj` | Seedance-2.0-fast | `0a476ef1-d4de-4364-8050-03261a890c23` | `cgt-20260624142611-kswz9` | 108,900 | 720p | 5s | 16:9 | 24 | 93047 | ✅ | default |
| 11 | `task_LRvXoCyhbjytg7nVqxi8SHVtjy14ICdU` | Seedance-2.0 | `5e1a7048-d88a-44ea-bba8-a6e0466eaa4a` | `cgt-20260624151204-l95pj` | 108,900 | 720p | 5s | 16:9 | 24 | 97198 | ❌ | default |
| 12 | `task_dDYpwuJr5GsjfHv1bCPSU2ep2QP6Oxwv` | Seedance-2.0-fast | `58ea6453-30d8-484d-a1ec-f223012a1cb2` | `cgt-20260625112024-dd74d` | 108,900 | 720p | 5s | 16:9 | 24 | 48027 | ❌ | default |

## 统计分布

### 模型分布

| 模型 | 数量 | 占比 |
|------|:---:|:---:|
| Doubao-Seedance-2.0 | 8 | 66.7% |
| Doubao-Seedance-2.0-fast | 4 | 33.3% |

### 分辨率分布

| 分辨率 | 数量 |
|--------|:---:|
| 480p | 1 |
| 720p | 10 |
| 1080p | 1 |

### Token 与分辨率关系

| 分辨率 | Total Tokens | 倍数 (vs 720p) |
|--------|:-----------:|:--------------:|
| 480p | 50,638 | ~0.46× |
| 720p | 108,900 | 1× (基准) |
| 1080p | 245,025 | ~2.25× |

### 其他参数

| 参数 | 分布 |
|------|------|
| 时长 | 全部 5s |
| 比例 | 16:9: 11 / 9:16: 1(竖屏) |
| FPS | 全部 24 |
| Audio | ✅ 生成: 8 / ❌ 不生成: 4 |
| Tier | 全部 default |

## 上游响应示例(task #1)

```json
{
"aiping_id": "ee43bb3f-af2f-4c22-a7e2-cfbf6ff2da01",
"id": "cgt-20260623152942-bvb94",
"model": "doubao-seedance-2-0-260128",
"status": "succeeded",
"content": {
"video_url": "https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/..."
},
"duration": 5,
"resolution": "720p",
"ratio": "16:9",
"seed": 23651,
"framespersecond": 24,
"service_tier": "default",
"generate_audio": true,
"execution_expires_after": 172800,
"usage": {
"total_tokens": 108900,
"completion_tokens": 108900
}
}
```

## 关键发现

1. **aiping_id**:上游返回的 UUID 格式标识符,被 `doubao_aiping/adaptor.go:178` 在返给客户端前删除,仅保存在 `tasks.data` 中
2. **上游 task_id**:`cgt-{YYYYMMDDHHmmss}-{5位随机}` 格式
3. **计费维度**:`total_tokens` 严格按分辨率变化 — 720p=108,900 是基准,480p 约减半,1080p 约翻 2.25 倍
4. **模型名映射**:上游返回原始名 `doubao-seedance-2-0-260128`,properties 中存储展示名 `Doubao-Seedance-2.0`

+ 32
- 2
relay/channel/task/kling/aiping/adaptor.go Vedi File

@@ -17,6 +17,7 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/shopspring/decimal"
)

type responsePayload struct {
@@ -37,8 +38,9 @@ type responsePayload struct {
Duration string `json:"duration"`
} `json:"videos"`
} `json:"task_result"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
FinalUnitDeduction any `json:"final_unit_deduction"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
} `json:"data"`
}

@@ -181,6 +183,10 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
case "succeed":
info.Status = model.TaskStatusSuccess
info.Progress = taskcommon.ProgressComplete
if tokens := finalUnitDeductionTokens(parsed.Data.FinalUnitDeduction); tokens > 0 {
info.CompletionTokens = tokens
info.TotalTokens = tokens
}
case "failed":
info.Status = model.TaskStatusFailure
info.Progress = taskcommon.ProgressComplete
@@ -194,6 +200,30 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
return info, nil
}

func finalUnitDeductionTokens(value any) int {
var d decimal.Decimal
var err error
switch v := value.(type) {
case string:
if strings.TrimSpace(v) == "" {
return 0
}
d, err = decimal.NewFromString(strings.TrimSpace(v))
case float64:
d = decimal.NewFromFloat(v)
case int:
d = decimal.NewFromInt(int64(v))
case int64:
d = decimal.NewFromInt(v)
default:
return 0
}
if err != nil || !d.IsPositive() {
return 0
}
return int(d.Mul(decimal.NewFromInt(1000000)).IntPart())
}

func (a *TaskAdaptor) GetModelList() []string {
return Models()
}


+ 47
- 0
relay/channel/task/kling/aiping/adaptor_test.go Vedi File

@@ -184,6 +184,53 @@ func TestValidateRequestRejectsOmniModelOnTextOrImageRoutes(t *testing.T) {
require.Contains(t, taskErr.Message, "/v1/videos/omni-video")
}

func TestParseTaskResultConvertsFinalUnitDeductionToTokens(t *testing.T) {
taskInfo, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{
"code": 0,
"message": "success",
"request_id": "req-1",
"data": {
"task_id": "upstream-task",
"task_status": "succeed",
"task_result": {
"videos": [
{"id": "v1", "url": "https://example.com/video.mp4", "duration": "5"}
]
},
"final_unit_deduction": "1.234567"
},
"aiping_id": "internal"
}`))

require.NoError(t, err)
require.Equal(t, 1234567, taskInfo.CompletionTokens)
require.Equal(t, 1234567, taskInfo.TotalTokens)
}

func TestFinalUnitDeductionTokens(t *testing.T) {
cases := []struct {
name string
input any
want int
}{
{"string decimal", "1.234567", 1234567},
{"float64", float64(1.234567), 1234567},
{"int", int(2), 2000000},
{"int64", int64(3), 3000000},
{"empty string", "", 0},
{"whitespace string", " ", 0},
{"zero float", float64(0), 0},
{"negative float", float64(-1.5), 0},
{"nil", nil, 0},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, finalUnitDeductionTokens(tc.input))
})
}
}

func TestSanitizeNativeTaskPayloadUsesPublicTaskIDAndAddsWatermarkURL(t *testing.T) {
payload := map[string]any{
"aiping_id": "internal",


+ 13
- 0
relay/helper/matrix_usage_capability.go Vedi File

@@ -23,6 +23,19 @@ var matrixUsageCapabilities = []MatrixUsageCapability{
{BillingModelName: "doubao-seedance-2-0-fast-260128", UpstreamModelName: "doubao-seedance-2-0-fast-260128", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleAiping},
{BillingModelName: "cdance2.0-0611", UpstreamModelName: "cdance2.0-0611", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleTianyiYun},
{BillingModelName: "cdance2.0-fast-0611", UpstreamModelName: "cdance2.0-fast-0611", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleTianyiYun},
// Keep in sync with SupportsAnyMatrixUsageBillingModel in setting/ratio_setting/model_pricing.go
{BillingModelName: "kling-v1", UpstreamModelName: "kling-v1", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-v1", UpstreamModelName: "Kling-V1", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-v1-6", UpstreamModelName: "kling-v1-6", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-v1-6", UpstreamModelName: "Kling-V1.6", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-v2-6", UpstreamModelName: "kling-v2-6", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-v2-6", UpstreamModelName: "Kling-V2.6", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-v3", UpstreamModelName: "kling-v3", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-v3", UpstreamModelName: "Kling-V3", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-video-o1", UpstreamModelName: "kling-video-o1", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-video-o1", UpstreamModelName: "Kling-Video-O1", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-v3-omni", UpstreamModelName: "kling-v3-omni", ChannelType: constant.ChannelTypeKlingAiping},
{BillingModelName: "kling-v3-omni", UpstreamModelName: "Kling-V3-Omni", ChannelType: constant.ChannelTypeKlingAiping},
}

func SupportsMatrixUsageBilling(modelName string, channelType int, mappedModel string) bool {


+ 19
- 0
relay/helper/matrix_usage_capability_test.go Vedi File

@@ -29,6 +29,25 @@ func TestSupportsMatrixUsageBilling_TianyiYunSeedanceChannel(t *testing.T) {
require.False(t, SupportsMatrixUsageBilling("cdance2.0-0611", constant.ChannelTypeDoubaoVideoCompatibleAiping, "cdance2.0-0611"))
}

func TestSupportsMatrixUsageBilling_KlingAipingVideoModels(t *testing.T) {
cases := []struct {
billingModel string
upstreamModel string
}{
{"kling-v1", "Kling-V1"},
{"kling-v1-6", "Kling-V1.6"},
{"kling-v2-6", "Kling-V2.6"},
{"kling-v3", "Kling-V3"},
{"kling-video-o1", "Kling-Video-O1"},
{"kling-v3-omni", "Kling-V3-Omni"},
}

for _, tc := range cases {
require.True(t, SupportsMatrixUsageBilling(tc.billingModel, constant.ChannelTypeKlingAiping, tc.billingModel), tc.billingModel)
require.True(t, SupportsMatrixUsageBilling(tc.billingModel, constant.ChannelTypeKlingAiping, tc.upstreamModel), tc.billingModel)
}
}

func TestResolvePricingModelMapping(t *testing.T) {
mapped, err := ResolvePricingModelMapping("seedance-2", `{"seedance-2":"doubao-video-seedance"}`)
require.NoError(t, err)


+ 40
- 0
service/task_billing_test.go Vedi File

@@ -913,6 +913,46 @@ func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) {
assert.Equal(t, int64(0), countLogs(t))
}

func TestSettle_MatrixPer1MTokensUsesTaskResultTotalTokens(t *testing.T) {
truncate(t)
ctx := context.Background()

const userID, tokenID, channelID = 32, 32, 32
const initQuota, preConsumed = 10000, 1000
const totalTokens = 1234567

seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-matrix-tokens", 8000)
seedChannel(t, channelID)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.PrivateData.BillingContext.BillingMode = types.BillingModeMatrix
task.PrivateData.BillingContext.BillingUnit = types.BillingUnitPer1MTokens
task.PrivateData.BillingContext.TokenUnitPriceUSD = 2
task.PrivateData.BillingContext.GroupRatio = 1
task.PrivateData.BillingContext.PricingSnapshot = map[string]any{
"billing_unit": types.BillingUnitPer1MTokens,
"price": float64(2),
"pricing_mode": string(types.PricingModeMatched),
}

settleTaskBillingOnComplete(ctx, &mockAdaptor{}, task, &relaycommon.TaskInfo{
Status: model.TaskStatusSuccess,
CompletionTokens: totalTokens,
TotalTokens: totalTokens,
})

expectedQuota := int(float64(totalTokens) * 2 / 1_000_000 * common.QuotaPerUnit)
assert.Equal(t, expectedQuota, task.Quota)

log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, totalTokens, log.CompletionTokens)
other := getLogOtherMap(t, log)
pricingSnapshot := other["pricing_snapshot"].(map[string]any)
assert.Equal(t, float64(totalTokens), pricingSnapshot["total_tokens"])
}

func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) {
truncate(t)
ctx := context.Background()


+ 4
- 1
setting/ratio_setting/model_pricing.go Vedi File

@@ -149,10 +149,13 @@ func ValidatePricingConfig(model string, cfg *types.PricingConfig) error {
}

func SupportsAnyMatrixUsageBillingModel(modelName string) bool {
// Keep in sync with matrixUsageCapabilities in relay/helper/matrix_usage_capability.go
switch modelName {
case "seedance-2", "doubao-video", "doubao-video-seedance",
"doubao-seedance-2-0-260128", "doubao-seedance-2-0-fast-260128",
"Doubao-Seedance-2.0", "Doubao-Seedance-2.0-fast":
"Doubao-Seedance-2.0", "Doubao-Seedance-2.0-fast",
"kling-v1", "kling-v1-6", "kling-v2-6", "kling-v3",
"kling-video-o1", "kling-v3-omni":
return true
default:
return false


+ 20
- 0
setting/ratio_setting/model_pricing_test.go Vedi File

@@ -76,6 +76,26 @@ func TestValidatePricingConfigUsageAllowlist(t *testing.T) {
require.NoError(t, ValidatePricingConfig("seedance-2", cfg))
}

func TestValidatePricingConfigUsageAllowlistKlingAipingModels(t *testing.T) {
models := []string{
"kling-v1",
"kling-v1-6",
"kling-v2-6",
"kling-v3",
"kling-video-o1",
"kling-v3-omni",
}

for _, model := range models {
t.Run(model, func(t *testing.T) {
cfg := validPricingConfig()
cfg.BillingUnit = types.BillingUnitPer1MTokens
cfg.PreconsumeStrategy = types.PreconsumeStrategyMinimum
require.NoError(t, ValidatePricingConfig(model, cfg))
})
}
}

func TestValidatePricingConfigAllowsDerivedVideoInput(t *testing.T) {
cfg := &types.PricingConfig{
SchemaVersion: types.PricingSchemaVersion,


+ 18
- 0
web/src/components/table/models/modals/modelEndpoints.utils.js Vedi File

@@ -178,6 +178,24 @@ export const normalizeEndpointSubmitValue = (rawValue, rows) => {
return { value: '', error: '' };
}

let rawParsed;
try {
rawParsed = JSON.parse(trimmed);
} catch (error) {
return { value: '', error: error.message };
}

if (Array.isArray(rawParsed)) {
return {
value: stringifyEndpointConfig(
rawParsed
.filter((endpoint) => typeof endpoint === 'string')
.map((endpoint) => createEndpointRow({ key: endpoint.trim() })),
),
error: '',
};
}

const parsed = parseEndpointConfig(rawValue);
if (parsed.error) {
return { value: '', error: parsed.error };


+ 19
- 0
web/src/components/table/models/modals/modelEndpoints.utils.test.js Vedi File

@@ -34,6 +34,25 @@ test('parseEndpointConfig rejects top-level array input', () => {
assert.deepEqual(result.rows, []);
});

test('normalizeEndpointSubmitValue accepts legacy endpoint array input', () => {
const result = normalizeEndpointSubmitValue('["openai"]', []);

assert.equal(result.error, '');
assert.equal(
result.value,
JSON.stringify(
{
openai: {
path: '/v1/chat/completions',
method: 'POST',
},
},
null,
2,
),
);
});

test('stringifyEndpointConfig builds endpoint object from structured rows', () => {
const result = stringifyEndpointConfig([
{ key: 'anthropic', path: '/v1/messages', method: 'post' },


+ 41
- 6
web/src/pages/Setting/Operation/ModelPricing/Editor.jsx Vedi File

@@ -12,7 +12,12 @@ import {
} from '@douyinfe/semi-ui';
import { Redo2, Save, Undo2, Wand2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../../../helpers';
import {
API,
getCurrencyConfig,
showError,
showSuccess,
} from '../../../../helpers';
import DimensionEditor from './DimensionEditor';
import GeneratorModal from './GeneratorModal';
import PreviewPanel from './PreviewPanel';
@@ -27,6 +32,8 @@ import {
BILLING_UNITS,
FALLBACKS,
createDefaultConfig,
pricingConfigFromDisplayCurrency,
pricingConfigToDisplayCurrency,
validatePricingConfig,
} from './pricingConfig';

@@ -40,6 +47,7 @@ export default function Editor({
onSaved,
}) {
const { t } = useTranslation();
const currencyConfig = getCurrencyConfig();
const [name, setName] = useState(modelName || '');
const [draft, setDraft] = useState(createDefaultConfig());
const [jsonText, setJsonText] = useState('');
@@ -56,7 +64,13 @@ export default function Editor({
const next = config ? structuredClone(config) : createDefaultConfig();
setName(modelName || '');
setDraft(next);
setJsonText(JSON.stringify(next, null, 2));
setJsonText(
JSON.stringify(
pricingConfigToDisplayCurrency(next, currencyConfig),
null,
2,
),
);
setDisplayPricingItemsDraft(
getEditableDisplayPricingItems(
modelName ? { [modelName]: displayPricingItems } : {},
@@ -76,7 +90,13 @@ export default function Editor({
setFuture([]);
setBillingDirty(true);
setDraft(next);
setJsonText(JSON.stringify(next, null, 2));
setJsonText(
JSON.stringify(
pricingConfigToDisplayCurrency(next, currencyConfig),
null,
2,
),
);
};

const undo = () => {
@@ -85,7 +105,13 @@ export default function Editor({
setHistory((items) => items.slice(0, -1));
setFuture((items) => [draft, ...items]);
setDraft(previous);
setJsonText(JSON.stringify(previous, null, 2));
setJsonText(
JSON.stringify(
pricingConfigToDisplayCurrency(previous, currencyConfig),
null,
2,
),
);
};

const redo = () => {
@@ -94,12 +120,21 @@ export default function Editor({
setFuture((items) => items.slice(1));
setHistory((items) => [...items, draft]);
setDraft(next);
setJsonText(JSON.stringify(next, null, 2));
setJsonText(
JSON.stringify(
pricingConfigToDisplayCurrency(next, currencyConfig),
null,
2,
),
);
};

const applyJson = () => {
try {
const next = JSON.parse(jsonText || '{}');
const next = pricingConfigFromDisplayCurrency(
JSON.parse(jsonText || '{}'),
currencyConfig,
);
updateDraft(next);
} catch (error) {
showError(`${t('JSON 格式错误')}: ${error.message}`);


+ 42
- 0
web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js Vedi File

@@ -279,6 +279,48 @@ export const formatDisplayCurrency = (
return `${symbol}${value.toFixed(digits)}`;
};

export const pricingConfigToDisplayCurrency = (config, currencyConfig) => {
const next = structuredClone(config || {});
next.table = (next.table || []).map((row) => ({
...row,
price: formatDisplayCurrencyNumber(row.price, currencyConfig),
}));
if (
next.fallback?.strategy === FALLBACKS.default &&
next.fallback.default_price !== undefined
) {
next.fallback = {
...next.fallback,
default_price: formatDisplayCurrencyNumber(
next.fallback.default_price,
currencyConfig,
),
};
}
return next;
};

export const pricingConfigFromDisplayCurrency = (config, currencyConfig) => {
const next = structuredClone(config || {});
next.table = (next.table || []).map((row) => ({
...row,
price: displayCurrencyToUSD(row.price, currencyConfig),
}));
if (
next.fallback?.strategy === FALLBACKS.default &&
next.fallback.default_price !== undefined
) {
next.fallback = {
...next.fallback,
default_price: displayCurrencyToUSD(
next.fallback.default_price,
currencyConfig,
),
};
}
return next;
};

export const getBillingUnitLabel = (billingUnit, t = (key) => key) => {
if (billingUnit === BILLING_UNITS.per1MTokens) return t('每 1M tokens');
return t('每次调用');


+ 33
- 3
web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js Vedi File

@@ -15,6 +15,8 @@ import {
importPricingCsv,
mergeGeneratedRows,
PRECONSUME_STRATEGIES,
pricingConfigFromDisplayCurrency,
pricingConfigToDisplayCurrency,
previewLookup,
ROW_SOURCES,
splitDimensionSource,
@@ -144,9 +146,9 @@ describe('pricingConfig utilities', () => {
).toBe(0.1);
expect(formatDisplayCurrency(0.1, cny, 4)).toBe('¥0.7000');
expect(formatDisplayCurrencyNumber(46, cny)).toBe(322);
expect(formatDisplayCurrencyNumber(displayCurrencyToUSD(37, cny), cny)).toBe(
37,
);
expect(
formatDisplayCurrencyNumber(displayCurrencyToUSD(37, cny), cny),
).toBe(37);
expect(getBillingUnitLabel(BILLING_UNITS.per1MTokens, (key) => key)).toBe(
'每 1M tokens',
);
@@ -155,6 +157,34 @@ describe('pricingConfig utilities', () => {
).toBe('¥ / 每 1M tokens');
});

test('convert pricing config JSON between stored USD and display currency', () => {
const cny = { symbol: 'CNY', rate: 7, type: 'CNY' };
const stored = {
schema_version: 1,
scope: 'model',
billing_unit: BILLING_UNITS.per1MTokens,
preconsume_strategy: PRECONSUME_STRATEGIES.minimum,
dimensions: [
{
key: 'duration',
source: 'request.duration',
type: 'string',
default: null,
},
],
table: [{ duration: '*', price: 1, source: ROW_SOURCES.manual }],
fallback: { strategy: 'default', default_price: 0.5 },
};

const display = pricingConfigToDisplayCurrency(stored, cny);
expect(display.table[0].price).toBe(7);
expect(display.fallback.default_price).toBe(3.5);

const nextStored = pricingConfigFromDisplayCurrency(display, cny);
expect(nextStored.table[0].price).toBe(1);
expect(nextStored.fallback.default_price).toBe(0.5);
});

test('csv import and export', () => {
const dimensions = [{ key: 'resolution', type: 'string' }];
const parsed = importPricingCsv(


Caricamento…
Annulla
Salva