diff --git a/controller/kling_aiping_native.go b/controller/kling_aiping_native.go index ebc62ad..50fda77 100644 --- a/controller/kling_aiping_native.go +++ b/controller/kling_aiping_native.go @@ -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 { diff --git a/controller/kling_aiping_native_test.go b/controller/kling_aiping_native_test.go index 9096534..6e29116 100644 --- a/controller/kling_aiping_native_test.go +++ b/controller/kling_aiping_native_test.go @@ -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" diff --git a/controller/model_meta.go b/controller/model_meta.go index fd36264..83e758c 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -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 diff --git a/controller/model_meta_test.go b/controller/model_meta_test.go new file mode 100644 index 0000000..b0aa52a --- /dev/null +++ b/controller/model_meta_test.go @@ -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) +} diff --git a/docs/seedance-aiping-cn-tasks.md b/docs/seedance-aiping-cn-tasks.md new file mode 100644 index 0000000..369d607 --- /dev/null +++ b/docs/seedance-aiping-cn-tasks.md @@ -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` diff --git a/relay/channel/task/kling/aiping/adaptor.go b/relay/channel/task/kling/aiping/adaptor.go index fb61f40..6e9044c 100644 --- a/relay/channel/task/kling/aiping/adaptor.go +++ b/relay/channel/task/kling/aiping/adaptor.go @@ -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() } diff --git a/relay/channel/task/kling/aiping/adaptor_test.go b/relay/channel/task/kling/aiping/adaptor_test.go index e3cca9a..11e248d 100644 --- a/relay/channel/task/kling/aiping/adaptor_test.go +++ b/relay/channel/task/kling/aiping/adaptor_test.go @@ -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", diff --git a/relay/helper/matrix_usage_capability.go b/relay/helper/matrix_usage_capability.go index 9fcd1f3..fe5d523 100644 --- a/relay/helper/matrix_usage_capability.go +++ b/relay/helper/matrix_usage_capability.go @@ -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 { diff --git a/relay/helper/matrix_usage_capability_test.go b/relay/helper/matrix_usage_capability_test.go index 93ed3fb..39b6d96 100644 --- a/relay/helper/matrix_usage_capability_test.go +++ b/relay/helper/matrix_usage_capability_test.go @@ -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) diff --git a/service/task_billing_test.go b/service/task_billing_test.go index eb3f004..98a3376 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -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() diff --git a/setting/ratio_setting/model_pricing.go b/setting/ratio_setting/model_pricing.go index 1c3d0dd..8126b0a 100644 --- a/setting/ratio_setting/model_pricing.go +++ b/setting/ratio_setting/model_pricing.go @@ -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 diff --git a/setting/ratio_setting/model_pricing_test.go b/setting/ratio_setting/model_pricing_test.go index ee341e1..ae452be 100644 --- a/setting/ratio_setting/model_pricing_test.go +++ b/setting/ratio_setting/model_pricing_test.go @@ -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, diff --git a/web/src/components/table/models/modals/modelEndpoints.utils.js b/web/src/components/table/models/modals/modelEndpoints.utils.js index 81e2284..4b121c2 100644 --- a/web/src/components/table/models/modals/modelEndpoints.utils.js +++ b/web/src/components/table/models/modals/modelEndpoints.utils.js @@ -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 }; diff --git a/web/src/components/table/models/modals/modelEndpoints.utils.test.js b/web/src/components/table/models/modals/modelEndpoints.utils.test.js index 3d178a7..03dd38e 100644 --- a/web/src/components/table/models/modals/modelEndpoints.utils.test.js +++ b/web/src/components/table/models/modals/modelEndpoints.utils.test.js @@ -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' }, diff --git a/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx b/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx index b656074..f8cd3f0 100644 --- a/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx +++ b/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx @@ -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}`); diff --git a/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js index 680335a..a7c50b5 100644 --- a/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js +++ b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js @@ -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('每次调用'); diff --git a/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js index a58cfea..d75aa9f 100644 --- a/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js +++ b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js @@ -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(