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
| @@ -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 { | |||
| @@ -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" | |||
| @@ -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 | |||
| @@ -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) | |||
| } | |||
| @@ -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` | |||
| @@ -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() | |||
| } | |||
| @@ -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", | |||
| @@ -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 { | |||
| @@ -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) | |||
| @@ -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() | |||
| @@ -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 | |||
| @@ -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, | |||
| @@ -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 }; | |||
| @@ -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' }, | |||
| @@ -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}`); | |||
| @@ -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('每次调用'); | |||
| @@ -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( | |||