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{ | relaycommon.StoreTaskRequest(c, relayInfo, route.Action, relaycommon.TaskSubmitReq{ | ||||
| Model: modelName, | Model: modelName, | ||||
| Prompt: stringFromMap(payload, "prompt"), | Prompt: stringFromMap(payload, "prompt"), | ||||
| Seconds: fmt.Sprint(payload["seconds"]), | |||||
| Duration: durationFromMap(payload), | |||||
| Metadata: 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) { | func KlingAipingNativeTaskFetch(c *gin.Context) { | ||||
| route, ok := klingaiping.FindRoute(c.Request.Method, c.FullPath(), klingaiping.RouteKindFetch) | route, ok := klingaiping.FindRoute(c.Request.Method, c.FullPath(), klingaiping.RouteKindFetch) | ||||
| if !ok { | if !ok { | ||||
| @@ -83,6 +83,22 @@ func TestConfigureKlingAipingTaskRelayInfoForcesChannelSelection(t *testing.T) { | |||||
| require.Nil(t, info.LockedChannel) | 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) { | func TestRequiredTaskChannelTypeOnlyMatchesKlingAipingNativePaths(t *testing.T) { | ||||
| c := newControllerJSONContext(t, "/v1/videos/text2video", `{}`) | c := newControllerJSONContext(t, "/v1/videos/text2video", `{}`) | ||||
| c.Request.URL.Path = "/v1/videos/text2video" | c.Request.URL.Path = "/v1/videos/text2video" | ||||
| @@ -97,6 +97,10 @@ func CreateModelMeta(c *gin.Context) { | |||||
| return | return | ||||
| } | } | ||||
| if strings.TrimSpace(m.Endpoints) == "" { | |||||
| m.Endpoints = `["openai"]` | |||||
| } | |||||
| if err := m.Insert(); err != nil { | if err := m.Insert(); err != nil { | ||||
| common.ApiError(c, err) | common.ApiError(c, err) | ||||
| return | return | ||||
| @@ -135,6 +139,10 @@ func UpdateModelMeta(c *gin.Context) { | |||||
| return | return | ||||
| } | } | ||||
| if strings.TrimSpace(m.Endpoints) == "" { | |||||
| m.Endpoints = `["openai"]` | |||||
| } | |||||
| if err := m.Update(); err != nil { | if err := m.Update(); err != nil { | ||||
| common.ApiError(c, err) | common.ApiError(c, err) | ||||
| return | 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/QuantumNous/new-api/service" | ||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "github.com/pkg/errors" | "github.com/pkg/errors" | ||||
| "github.com/shopspring/decimal" | |||||
| ) | ) | ||||
| type responsePayload struct { | type responsePayload struct { | ||||
| @@ -37,8 +38,9 @@ type responsePayload struct { | |||||
| Duration string `json:"duration"` | Duration string `json:"duration"` | ||||
| } `json:"videos"` | } `json:"videos"` | ||||
| } `json:"task_result"` | } `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"` | } `json:"data"` | ||||
| } | } | ||||
| @@ -181,6 +183,10 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e | |||||
| case "succeed": | case "succeed": | ||||
| info.Status = model.TaskStatusSuccess | info.Status = model.TaskStatusSuccess | ||||
| info.Progress = taskcommon.ProgressComplete | info.Progress = taskcommon.ProgressComplete | ||||
| if tokens := finalUnitDeductionTokens(parsed.Data.FinalUnitDeduction); tokens > 0 { | |||||
| info.CompletionTokens = tokens | |||||
| info.TotalTokens = tokens | |||||
| } | |||||
| case "failed": | case "failed": | ||||
| info.Status = model.TaskStatusFailure | info.Status = model.TaskStatusFailure | ||||
| info.Progress = taskcommon.ProgressComplete | info.Progress = taskcommon.ProgressComplete | ||||
| @@ -194,6 +200,30 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e | |||||
| return info, nil | 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 { | func (a *TaskAdaptor) GetModelList() []string { | ||||
| return Models() | return Models() | ||||
| } | } | ||||
| @@ -184,6 +184,53 @@ func TestValidateRequestRejectsOmniModelOnTextOrImageRoutes(t *testing.T) { | |||||
| require.Contains(t, taskErr.Message, "/v1/videos/omni-video") | 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) { | func TestSanitizeNativeTaskPayloadUsesPublicTaskIDAndAddsWatermarkURL(t *testing.T) { | ||||
| payload := map[string]any{ | payload := map[string]any{ | ||||
| "aiping_id": "internal", | "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: "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-0611", UpstreamModelName: "cdance2.0-0611", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleTianyiYun}, | ||||
| {BillingModelName: "cdance2.0-fast-0611", UpstreamModelName: "cdance2.0-fast-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 { | 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")) | 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) { | func TestResolvePricingModelMapping(t *testing.T) { | ||||
| mapped, err := ResolvePricingModelMapping("seedance-2", `{"seedance-2":"doubao-video-seedance"}`) | mapped, err := ResolvePricingModelMapping("seedance-2", `{"seedance-2":"doubao-video-seedance"}`) | ||||
| require.NoError(t, err) | require.NoError(t, err) | ||||
| @@ -913,6 +913,46 @@ func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) { | |||||
| assert.Equal(t, int64(0), countLogs(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) { | func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) { | ||||
| truncate(t) | truncate(t) | ||||
| ctx := context.Background() | ctx := context.Background() | ||||
| @@ -149,10 +149,13 @@ func ValidatePricingConfig(model string, cfg *types.PricingConfig) error { | |||||
| } | } | ||||
| func SupportsAnyMatrixUsageBillingModel(modelName string) bool { | func SupportsAnyMatrixUsageBillingModel(modelName string) bool { | ||||
| // Keep in sync with matrixUsageCapabilities in relay/helper/matrix_usage_capability.go | |||||
| switch modelName { | switch modelName { | ||||
| case "seedance-2", "doubao-video", "doubao-video-seedance", | case "seedance-2", "doubao-video", "doubao-video-seedance", | ||||
| "doubao-seedance-2-0-260128", "doubao-seedance-2-0-fast-260128", | "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 | return true | ||||
| default: | default: | ||||
| return false | return false | ||||
| @@ -76,6 +76,26 @@ func TestValidatePricingConfigUsageAllowlist(t *testing.T) { | |||||
| require.NoError(t, ValidatePricingConfig("seedance-2", cfg)) | 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) { | func TestValidatePricingConfigAllowsDerivedVideoInput(t *testing.T) { | ||||
| cfg := &types.PricingConfig{ | cfg := &types.PricingConfig{ | ||||
| SchemaVersion: types.PricingSchemaVersion, | SchemaVersion: types.PricingSchemaVersion, | ||||
| @@ -178,6 +178,24 @@ export const normalizeEndpointSubmitValue = (rawValue, rows) => { | |||||
| return { value: '', error: '' }; | 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); | const parsed = parseEndpointConfig(rawValue); | ||||
| if (parsed.error) { | if (parsed.error) { | ||||
| return { value: '', error: parsed.error }; | return { value: '', error: parsed.error }; | ||||
| @@ -34,6 +34,25 @@ test('parseEndpointConfig rejects top-level array input', () => { | |||||
| assert.deepEqual(result.rows, []); | 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', () => { | test('stringifyEndpointConfig builds endpoint object from structured rows', () => { | ||||
| const result = stringifyEndpointConfig([ | const result = stringifyEndpointConfig([ | ||||
| { key: 'anthropic', path: '/v1/messages', method: 'post' }, | { key: 'anthropic', path: '/v1/messages', method: 'post' }, | ||||
| @@ -12,7 +12,12 @@ import { | |||||
| } from '@douyinfe/semi-ui'; | } from '@douyinfe/semi-ui'; | ||||
| import { Redo2, Save, Undo2, Wand2 } from 'lucide-react'; | import { Redo2, Save, Undo2, Wand2 } from 'lucide-react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { API, showError, showSuccess } from '../../../../helpers'; | |||||
| import { | |||||
| API, | |||||
| getCurrencyConfig, | |||||
| showError, | |||||
| showSuccess, | |||||
| } from '../../../../helpers'; | |||||
| import DimensionEditor from './DimensionEditor'; | import DimensionEditor from './DimensionEditor'; | ||||
| import GeneratorModal from './GeneratorModal'; | import GeneratorModal from './GeneratorModal'; | ||||
| import PreviewPanel from './PreviewPanel'; | import PreviewPanel from './PreviewPanel'; | ||||
| @@ -27,6 +32,8 @@ import { | |||||
| BILLING_UNITS, | BILLING_UNITS, | ||||
| FALLBACKS, | FALLBACKS, | ||||
| createDefaultConfig, | createDefaultConfig, | ||||
| pricingConfigFromDisplayCurrency, | |||||
| pricingConfigToDisplayCurrency, | |||||
| validatePricingConfig, | validatePricingConfig, | ||||
| } from './pricingConfig'; | } from './pricingConfig'; | ||||
| @@ -40,6 +47,7 @@ export default function Editor({ | |||||
| onSaved, | onSaved, | ||||
| }) { | }) { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const currencyConfig = getCurrencyConfig(); | |||||
| const [name, setName] = useState(modelName || ''); | const [name, setName] = useState(modelName || ''); | ||||
| const [draft, setDraft] = useState(createDefaultConfig()); | const [draft, setDraft] = useState(createDefaultConfig()); | ||||
| const [jsonText, setJsonText] = useState(''); | const [jsonText, setJsonText] = useState(''); | ||||
| @@ -56,7 +64,13 @@ export default function Editor({ | |||||
| const next = config ? structuredClone(config) : createDefaultConfig(); | const next = config ? structuredClone(config) : createDefaultConfig(); | ||||
| setName(modelName || ''); | setName(modelName || ''); | ||||
| setDraft(next); | setDraft(next); | ||||
| setJsonText(JSON.stringify(next, null, 2)); | |||||
| setJsonText( | |||||
| JSON.stringify( | |||||
| pricingConfigToDisplayCurrency(next, currencyConfig), | |||||
| null, | |||||
| 2, | |||||
| ), | |||||
| ); | |||||
| setDisplayPricingItemsDraft( | setDisplayPricingItemsDraft( | ||||
| getEditableDisplayPricingItems( | getEditableDisplayPricingItems( | ||||
| modelName ? { [modelName]: displayPricingItems } : {}, | modelName ? { [modelName]: displayPricingItems } : {}, | ||||
| @@ -76,7 +90,13 @@ export default function Editor({ | |||||
| setFuture([]); | setFuture([]); | ||||
| setBillingDirty(true); | setBillingDirty(true); | ||||
| setDraft(next); | setDraft(next); | ||||
| setJsonText(JSON.stringify(next, null, 2)); | |||||
| setJsonText( | |||||
| JSON.stringify( | |||||
| pricingConfigToDisplayCurrency(next, currencyConfig), | |||||
| null, | |||||
| 2, | |||||
| ), | |||||
| ); | |||||
| }; | }; | ||||
| const undo = () => { | const undo = () => { | ||||
| @@ -85,7 +105,13 @@ export default function Editor({ | |||||
| setHistory((items) => items.slice(0, -1)); | setHistory((items) => items.slice(0, -1)); | ||||
| setFuture((items) => [draft, ...items]); | setFuture((items) => [draft, ...items]); | ||||
| setDraft(previous); | setDraft(previous); | ||||
| setJsonText(JSON.stringify(previous, null, 2)); | |||||
| setJsonText( | |||||
| JSON.stringify( | |||||
| pricingConfigToDisplayCurrency(previous, currencyConfig), | |||||
| null, | |||||
| 2, | |||||
| ), | |||||
| ); | |||||
| }; | }; | ||||
| const redo = () => { | const redo = () => { | ||||
| @@ -94,12 +120,21 @@ export default function Editor({ | |||||
| setFuture((items) => items.slice(1)); | setFuture((items) => items.slice(1)); | ||||
| setHistory((items) => [...items, draft]); | setHistory((items) => [...items, draft]); | ||||
| setDraft(next); | setDraft(next); | ||||
| setJsonText(JSON.stringify(next, null, 2)); | |||||
| setJsonText( | |||||
| JSON.stringify( | |||||
| pricingConfigToDisplayCurrency(next, currencyConfig), | |||||
| null, | |||||
| 2, | |||||
| ), | |||||
| ); | |||||
| }; | }; | ||||
| const applyJson = () => { | const applyJson = () => { | ||||
| try { | try { | ||||
| const next = JSON.parse(jsonText || '{}'); | |||||
| const next = pricingConfigFromDisplayCurrency( | |||||
| JSON.parse(jsonText || '{}'), | |||||
| currencyConfig, | |||||
| ); | |||||
| updateDraft(next); | updateDraft(next); | ||||
| } catch (error) { | } catch (error) { | ||||
| showError(`${t('JSON 格式错误')}: ${error.message}`); | showError(`${t('JSON 格式错误')}: ${error.message}`); | ||||
| @@ -279,6 +279,48 @@ export const formatDisplayCurrency = ( | |||||
| return `${symbol}${value.toFixed(digits)}`; | 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) => { | export const getBillingUnitLabel = (billingUnit, t = (key) => key) => { | ||||
| if (billingUnit === BILLING_UNITS.per1MTokens) return t('每 1M tokens'); | if (billingUnit === BILLING_UNITS.per1MTokens) return t('每 1M tokens'); | ||||
| return t('每次调用'); | return t('每次调用'); | ||||
| @@ -15,6 +15,8 @@ import { | |||||
| importPricingCsv, | importPricingCsv, | ||||
| mergeGeneratedRows, | mergeGeneratedRows, | ||||
| PRECONSUME_STRATEGIES, | PRECONSUME_STRATEGIES, | ||||
| pricingConfigFromDisplayCurrency, | |||||
| pricingConfigToDisplayCurrency, | |||||
| previewLookup, | previewLookup, | ||||
| ROW_SOURCES, | ROW_SOURCES, | ||||
| splitDimensionSource, | splitDimensionSource, | ||||
| @@ -144,9 +146,9 @@ describe('pricingConfig utilities', () => { | |||||
| ).toBe(0.1); | ).toBe(0.1); | ||||
| expect(formatDisplayCurrency(0.1, cny, 4)).toBe('¥0.7000'); | expect(formatDisplayCurrency(0.1, cny, 4)).toBe('¥0.7000'); | ||||
| expect(formatDisplayCurrencyNumber(46, cny)).toBe(322); | 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( | expect(getBillingUnitLabel(BILLING_UNITS.per1MTokens, (key) => key)).toBe( | ||||
| '每 1M tokens', | '每 1M tokens', | ||||
| ); | ); | ||||
| @@ -155,6 +157,34 @@ describe('pricingConfig utilities', () => { | |||||
| ).toBe('¥ / 每 1M tokens'); | ).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', () => { | test('csv import and export', () => { | ||||
| const dimensions = [{ key: 'resolution', type: 'string' }]; | const dimensions = [{ key: 'resolution', type: 'string' }]; | ||||
| const parsed = importPricingCsv( | const parsed = importPricingCsv( | ||||