diff --git a/backend/internal/protocol/builtin.go b/backend/internal/protocol/builtin.go index f590a4602..9ac43bd0b 100644 --- a/backend/internal/protocol/builtin.go +++ b/backend/internal/protocol/builtin.go @@ -752,7 +752,10 @@ func parseAgnesPoll(c PollContext, payload map[string]any) (PollResult, error) { metadata := object(payload["metadata"]) videoURL := firstString(metadata, "url") if videoURL == "" { - return PollResult{}, fmt.Errorf("Agnes 完成响应没有 metadata.url") + videoURL = firstString(payload, "url") + } + if videoURL == "" { + return PollResult{}, fmt.Errorf("Agnes 完成响应没有返回视频 URL") } return PollResult{TaskID: id, Status: status, Result: &Result{Videos: []MediaReference{{URL: videoURL, Kind: string(CapabilityVideo)}}}}, nil } diff --git a/backend/internal/protocol/builtin_test.go b/backend/internal/protocol/builtin_test.go index abd3b6a4d..8056838d7 100644 --- a/backend/internal/protocol/builtin_test.go +++ b/backend/internal/protocol/builtin_test.go @@ -242,6 +242,10 @@ func TestAgnesMapsOfficialCreateAndPollContracts(t *testing.T) { if err != nil || completed.Status != StatusSucceeded || completed.Result == nil || completed.Result.Videos[0].URL != "https://cdn.example/result.mp4" { t.Fatalf("completed = %#v, err = %v", completed, err) } + completedV20, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "video-2"}, []byte(`{"id":"video-2","status":"completed","url":"https://cdn.example/v20-result.mp4"}`)) + if err != nil || completedV20.Status != StatusSucceeded || completedV20.Result == nil || completedV20.Result.Videos[0].URL != "https://cdn.example/v20-result.mp4" { + t.Fatalf("completed v2.0 = %#v, err = %v", completedV20, err) + } failed, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "video-1"}, []byte(`{"video_id":"video-1","status":"failed","error":{"message":"quota exceeded"}}`)) if err != nil || failed.Status != StatusFailed || failed.Message != "quota exceeded" { t.Fatalf("failed = %#v, err = %v", failed, err) diff --git a/backend/internal/protocol/docs/agnes-video.md b/backend/internal/protocol/docs/agnes-video.md index f8ce57071..65e0afdfb 100644 --- a/backend/internal/protocol/docs/agnes-video.md +++ b/backend/internal/protocol/docs/agnes-video.md @@ -32,7 +32,7 @@ Agnes AI 官方视频接口使用 Bearer API Key 和异步任务模型。影策 ## 响应、状态与错误 -创建成功响应中的 `video_id` 是推荐轮询标识。状态映射为:`queued` -> 等待,`in_progress` -> 处理中,`completed` -> 成功,`failed` -> 失败。成功结果只读取官方 `metadata.url`;如果响应宣称完成却没有该字段,影策会返回真实协议错误,不生成假 URL。失败信息优先读取 `error.message`,其次读取顶层 `message` 或 `detail`。 +创建成功响应中的 `video_id` 是推荐轮询标识。状态映射为:`queued` -> 等待,`in_progress` -> 处理中,`completed` -> 成功,`failed` -> 失败。成功结果优先读取 2.5 官方结构中的 `metadata.url`,并兼容 V2.0 返回的顶层 `url`;如果响应宣称完成却没有视频 URL,影策会返回真实协议错误,不生成假 URL。失败信息优先读取 `error.message`,其次读取顶层 `message` 或 `detail`。 参考媒体必须是 Agnes 服务可公开访问且在任务完成前持续有效的 URL。适配器声明了公共媒体 URL 要求,影策运行时会按现有资源流程准备上游可访问地址;本机、私网、Cookie 保护或很快过期的链接不能作为 Agnes 输入。 diff --git a/backend/internal/repository/analytics.go b/backend/internal/repository/analytics.go index 065fddae6..ef091ffd7 100644 --- a/backend/internal/repository/analytics.go +++ b/backend/internal/repository/analytics.go @@ -144,8 +144,8 @@ func (r *Repository) filteredAPICallLogQuery(filter APICallLogFilter) *gorm.DB { Joins("LEFT JOIN users ON users.id = api_call_logs.user_id"). Joins("LEFT JOIN model_channels ON model_channels.id = api_call_logs.channel_id"). Where( - "lower(api_call_logs.user_id) LIKE ? OR lower(users.username) LIKE ? OR lower(users.display_name) LIKE ? OR lower(api_call_logs.channel_id) LIKE ? OR lower(model_channels.name) LIKE ? OR lower(api_call_logs.model) LIKE ? OR lower(api_call_logs.path) LIKE ? OR lower(api_call_logs.provider_request_id) LIKE ?", - pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern, + "lower(api_call_logs.user_id) LIKE ? OR lower(users.username) LIKE ? OR lower(users.display_name) LIKE ? OR lower(api_call_logs.channel_id) LIKE ? OR lower(model_channels.name) LIKE ? OR lower(api_call_logs.model) LIKE ? OR lower(api_call_logs.path) LIKE ? OR lower(api_call_logs.provider_request_id) LIKE ? OR lower(api_call_logs.error_code) LIKE ? OR lower(api_call_logs.error) LIKE ?", + pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern, ) } if filter.Status != "" { @@ -172,6 +172,15 @@ func (r *Repository) LatestProviderRequestIDForTask(taskID string) (string, erro return strings.TrimSpace(log.ProviderRequestID), err } +func (r *Repository) HasAPICallLogForTask(taskID string) (bool, error) { + if strings.TrimSpace(taskID) == "" { + return false, nil + } + var count int64 + err := r.db.Model(&model.ApiCallLog{}).Where("task_id = ?", taskID).Count(&count).Error + return count > 0, err +} + func visibleAPICallLogQuery(query *gorm.DB) *gorm.DB { // 视频轮询属于一次生成调用的内部阶段,管理端只展示聚合后的创建主记录。 return query.Where("NOT (api_call_logs.capability = ? AND api_call_logs.request_kind IN ?)", "video", []string{"poll", "download"}) diff --git a/backend/internal/repository/analytics_test.go b/backend/internal/repository/analytics_test.go index f3699884b..e585b1a63 100644 --- a/backend/internal/repository/analytics_test.go +++ b/backend/internal/repository/analytics_test.go @@ -6,7 +6,10 @@ import ( "testing" "time" + "infinite-canvas/backend/internal/model" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) @@ -71,3 +74,41 @@ func TestRecordUserActivityQualifiesPostgresConflictColumns(t *testing.T) { }) } } + +func TestQueryAPICallLogsSearchesFailureFields(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:api-log-error-search?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.User{}, &model.ModelChannel{}, &model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + now := time.Now() + item := model.ApiCallLog{ + ID: "api-log-1", + UserID: "user-1", + RequestKind: "create", + Status: model.ApiCallStatusFailed, + ErrorCode: "request_not_sent", + Error: "模型服务拒绝了请求,请检查模型和参数;上游:invalid parameter: size", + CreatedAt: now, + } + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + repo := New(db) + for _, keyword := range []string{"模型服务拒绝", "invalid parameter", "request_not_sent"} { + logs, total, err := repo.QueryAPICallLogs(APICallLogFilter{ + AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)}, + Keyword: keyword, + Page: 1, + Limit: 20, + }) + if err != nil { + t.Fatalf("QueryAPICallLogs(%q) error = %v", keyword, err) + } + if total != 1 || len(logs) != 1 || logs[0].ID != item.ID { + t.Fatalf("QueryAPICallLogs(%q) = total:%d logs:%#v", keyword, total, logs) + } + } +} diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index 352d6b817..7075d20a1 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -942,12 +942,29 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { log.ProviderRequestID = providerRequestIDFromPath(log.Path) } payloads := providerResponsePayloads(responseBody) - if len(payloads) == 0 { - return - } for _, payload := range payloads { s.enrichAPICallLogPayload(log, payload) } + s.enrichAPICallLogFailureSummary(log, responseBody) +} + +func (s *Service) enrichAPICallLogFailureSummary(log *model.ApiCallLog, responseBody []byte) { + if log.Status != model.ApiCallStatusFailed || log.StatusCode < 400 { + return + } + userMessage := providerUserFacingErrorMessage(providerHTTPError{ + StatusCode: log.StatusCode, + Body: string(responseBody), + }) + detail := strings.TrimSpace(log.Error) + if detail == "" || detail == userMessage { + log.Error = userMessage + return + } + if strings.Contains(detail, userMessage) { + return + } + log.Error = truncateRunes(userMessage+";上游:"+detail, 2_000) } func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) { diff --git a/backend/internal/service/finance_token_test.go b/backend/internal/service/finance_token_test.go index b222b4724..02b18a070 100644 --- a/backend/internal/service/finance_token_test.go +++ b/backend/internal/service/finance_token_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "strings" "testing" "infinite-canvas/backend/internal/model" @@ -85,6 +86,24 @@ func TestEnrichAPICallLogReadsArkVideoUsage(t *testing.T) { } } +func TestEnrichAPICallLogKeepsUserFacingAndUpstreamFailureDetails(t *testing.T) { + log := &model.ApiCallLog{ + Status: model.ApiCallStatusFailed, + StatusCode: 400, + Error: "上游 HTTP 400", + } + (&Service{}).EnrichAPICallLog(log, []byte(`{"error":{"code":"invalid_request","message":"invalid parameter: size"}}`)) + if !strings.Contains(log.Error, "模型服务拒绝了请求,请检查模型和参数") { + t.Fatalf("EnrichAPICallLog() missing user-facing category: %q", log.Error) + } + if !strings.Contains(log.Error, "invalid parameter: size") { + t.Fatalf("EnrichAPICallLog() missing upstream detail: %q", log.Error) + } + if log.ErrorCode != "invalid_request" { + t.Fatalf("EnrichAPICallLog() error code = %q", log.ErrorCode) + } +} + func TestEnrichAPICallLogReadsChatCompletionStreamUsage(t *testing.T) { log := &model.ApiCallLog{Capability: "text", Path: "/v1/chat/completions"} stream := []byte("data: {\"id\":\"chatcmpl-test\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n" + diff --git a/backend/internal/service/task_api_call_log.go b/backend/internal/service/task_api_call_log.go new file mode 100644 index 000000000..cacefa3f0 --- /dev/null +++ b/backend/internal/service/task_api_call_log.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "errors" + "log" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +// ensureFailedProviderAttemptLogged fills the task-level audit gap only when no +// provider log exists. This normally represents a preflight failure; the coarse +// request/state variants also preserve visibility if primary log persistence failed. +func (s *Service) ensureFailedProviderAttemptLogged(task model.Task, taskErr error) { + if s == nil || s.repo == nil || strings.TrimSpace(task.ID) == "" || taskErr == nil { + return + } + hasLog, err := s.repo.HasAPICallLogForTask(task.ID) + if err != nil { + log.Printf("provider attempt log lookup failed: task_id=%s error=%v", task.ID, err) + return + } + if hasLog { + return + } + + metadata := providerAnalyticsContext{ + UserID: task.UserID, + TaskID: task.ID, + TraceID: task.TraceID, + RequestID: task.RequestID, + BillingOrderID: task.BillingOrderID, + Capability: capabilityFromTaskType(task.Type), + Operation: task.Operation, + Model: task.Model, + ProviderRequestID: task.ProviderRequestID, + } + decodedTask := task + if decrypted, decryptErr := s.decryptTaskInputJSON(task.InputJSON); decryptErr == nil { + decodedTask.InputJSON = decrypted + if value, ok := withProviderAnalytics(context.Background(), s, decodedTask).Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok { + metadata = value + } + } + + now := time.Now() + startedAt := now + if task.StartedAt != nil && !task.StartedAt.IsZero() { + startedAt = *task.StartedAt + } + path := "/task/provider-preflight" + errorCode := "request_not_sent" + statusCode := 0 + var httpErr providerHTTPError + if errors.As(taskErr, &httpErr) { + path = "/task/provider-request" + errorCode = "provider_request_unlogged" + statusCode = httpErr.StatusCode + } else if strings.TrimSpace(metadata.ProviderRequestID) != "" { + path = "/task/provider-state" + errorCode = "provider_state_unlogged" + } + callLog := model.ApiCallLog{ + UserID: metadata.UserID, + TraceID: metadata.TraceID, + RequestID: metadata.RequestID, + ChannelID: metadata.ChannelID, + TaskID: metadata.TaskID, + BillingOrderID: metadata.BillingOrderID, + Source: "backend-task", + Capability: metadata.Capability, + Operation: metadata.Operation, + RequestKind: "create", + Billable: false, + APIFormat: "internal", + Method: "INTERNAL", + Path: path, + Model: metadata.Model, + Status: model.ApiCallStatusFailed, + StatusCode: statusCode, + DurationMs: now.Sub(startedAt).Milliseconds(), + ProviderRequestID: metadata.ProviderRequestID, + ErrorCode: errorCode, + Error: truncateRunes(s.UserFacingErrorMessage(taskErr), 2_000), + StartedAt: startedAt, + CreatedAt: now, + } + if err := s.LogAPICall(callLog); err != nil { + log.Printf("provider attempt log write failed: task_id=%s error=%v", task.ID, err) + } +} diff --git a/backend/internal/service/task_api_call_log_test.go b/backend/internal/service/task_api_call_log_test.go new file mode 100644 index 000000000..51e5cb84e --- /dev/null +++ b/backend/internal/service/task_api_call_log_test.go @@ -0,0 +1,98 @@ +package service + +import ( + "errors" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestEnsureFailedProviderAttemptLoggedFillsPreflightGapOnce(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:provider-preflight-log?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &model.SystemSetting{}, + &model.Asset{}, + &model.CanvasProject{}, + &model.Session{}, + &model.Message{}, + &model.Task{}, + &model.TaskLog{}, + &model.Result{}, + &model.ApiCallLog{}, + &model.TaskTextDelta{}, + &model.ModelPricing{}, + ); err != nil { + t.Fatal(err) + } + task := model.Task{ + ID: "task-1", + UserID: "user-1", + Type: "canvas_video", + Status: model.TaskStatusRunning, + Operation: "video.generate", + Model: "logical-video", + InputJSON: `{"mode":"video","config":{"channelId":"channel-1","channelModelKey":"agnes-video-2.5","apiKey":"secret-key"}}`, + } + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + service := &Service{repo: repository.New(db)} + failure := errors.New("Agnes Video 2.5 size 必须是 720P、960P 或 2K") + + service.ensureFailedProviderAttemptLogged(task, failure) + service.ensureFailedProviderAttemptLogged(task, failure) + + var logs []model.ApiCallLog + if err := db.Find(&logs).Error; err != nil { + t.Fatal(err) + } + if len(logs) != 1 { + t.Fatalf("api call log count = %d, want 1", len(logs)) + } + logged := logs[0] + if logged.ErrorCode != "request_not_sent" || logged.Status != model.ApiCallStatusFailed || logged.StatusCode != 0 { + t.Fatalf("unexpected preflight log state: %#v", logged) + } + if logged.ChannelID != "channel-1" || logged.Model != "agnes-video-2.5" || logged.Capability != "video" { + t.Fatalf("unexpected provider metadata: %#v", logged) + } + if logged.Error != failure.Error() { + t.Fatalf("preflight error = %q, want %q", logged.Error, failure.Error()) + } + serialized := logged.RequestBody + logged.ResponseBody + logged.UpstreamURL + logged.Error + if strings.Contains(serialized, "secret-key") || logged.RequestBody != "" || logged.ResponseBody != "" { + t.Fatalf("preflight log leaked task input: %#v", logged) + } +} + +func TestEnsureFailedProviderAttemptLoggedDoesNotDuplicateHTTPLog(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:provider-http-log-dedup?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + existing := model.ApiCallLog{ID: "api-log-1", TaskID: "task-1", Status: model.ApiCallStatusFailed} + if err := db.Create(&existing).Error; err != nil { + t.Fatal(err) + } + service := &Service{repo: repository.New(db)} + service.ensureFailedProviderAttemptLogged(model.Task{ID: "task-1"}, errors.New("provider rejected request")) + + var count int64 + if err := db.Model(&model.ApiCallLog{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("api call log count = %d, want 1", count) + } +} diff --git a/backend/internal/service/task_terminal.go b/backend/internal/service/task_terminal.go index b2a000512..e34655c77 100644 --- a/backend/internal/service/task_terminal.go +++ b/backend/internal/service/task_terminal.go @@ -22,6 +22,7 @@ type taskTerminalCoordinator struct { logger taskLifecycleLogger outputs taskOutputLifecycle userFacingMessage func(error) string + logFailedAttempt func(model.Task, error) } type taskTerminalRepository interface { @@ -61,6 +62,7 @@ func newTaskTerminalCoordinator(s *Service) *taskTerminalCoordinator { logger: s, outputs: s, userFacingMessage: s.UserFacingErrorMessage, + logFailedAttempt: s.ensureFailedProviderAttemptLogged, } } @@ -73,6 +75,7 @@ func (s *Service) terminalCoordinator() *taskTerminalCoordinator { } func (c *taskTerminalCoordinator) markPreparationFailure(task *model.Task, stage string, err error, billingUncertain bool, refundReason string) error { + c.ensureFailedAttemptLogged(task, err) task.Status = model.TaskStatusFailed task.Stage = stage task.Error = c.userFacingMessage(err) @@ -126,6 +129,7 @@ func (c *taskTerminalCoordinator) handleExecutionFailure(task *model.Task, err e } task.Status = model.TaskStatusFailed + c.ensureFailedAttemptLogged(task, err) task.Stage = "任务失败" task.Error = c.userFacingMessage(err) if terminalErr := c.markTerminalState(task); terminalErr != nil { @@ -208,6 +212,13 @@ func (c *taskTerminalCoordinator) handleResultPersistenceFailure(task *model.Tas return false, resultErr } +func (c *taskTerminalCoordinator) ensureFailedAttemptLogged(task *model.Task, err error) { + if c.logFailedAttempt == nil || task == nil || err == nil { + return + } + c.logFailedAttempt(*task, err) +} + func (c *taskTerminalCoordinator) handleSuccess(task *model.Task) error { c.finalizeReplay(task, model.TaskStatusSucceeded, "文本回放窗口更新失败") var completionErr error diff --git a/backend/internal/service/task_terminal_test.go b/backend/internal/service/task_terminal_test.go index dd5215ffc..43001d8b8 100644 --- a/backend/internal/service/task_terminal_test.go +++ b/backend/internal/service/task_terminal_test.go @@ -170,6 +170,32 @@ func TestTaskTerminalCoordinatorRefundsFailureBeforeProviderRequest(t *testing.T } } +func TestTaskTerminalCoordinatorLogsUnrecordedProviderFailure(t *testing.T) { + task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1"} + coordinator := newTaskTerminalCoordinatorForTest( + &taskTerminalRepositoryStub{task: task}, + &taskTerminalBillingStub{}, + &taskTerminalReplayStub{}, + &taskTerminalSessionStub{}, + &taskTerminalLoggerStub{}, + &taskTerminalOutputStub{}, + ) + var loggedTask model.Task + var loggedErr error + coordinator.logFailedAttempt = func(value model.Task, err error) { + loggedTask = value + loggedErr = err + } + failure := errors.New("provider preflight failed") + + if err := coordinator.handleExecutionFailure(task, failure, false, true); !errors.Is(err, failure) { + t.Fatalf("handleExecutionFailure() error = %v, want %v", err, failure) + } + if loggedTask.ID != task.ID || !errors.Is(loggedErr, failure) { + t.Fatalf("logged failure = task:%#v error:%v", loggedTask, loggedErr) + } +} + func TestTaskTerminalCoordinatorReturnsTerminalStateWriteError(t *testing.T) { task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1"} terminalError := errors.New("database unavailable") diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index ca3b86c3c..99700edf2 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -11,9 +11,18 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [x] 后端专项测试确认内置 `host:agnes-video` 插件直接进入 Agnes JSON 协议执行器,不再回退到 OpenAI 风格 multipart;旧渠道能力读取时会收紧为官方 4–12 秒和 720P/960P/2K 档位。 - [x] Agnes 插件升级到 `1.2.0`:公共层像素画幅(如 `1280x720`)只转换为 `aspect_ratio: "16:9"`,公共层分辨率(如 `720`/`720p`)只转换为 `size: "720P"`,不再把两类字段混用。 - [x] 后端专项测试确认自定义渠道和系统渠道均允许官方 `/agnesapi?video_id=...&model_name=...` 轮询,并将该路径转发到 Agnes 主机根路径。 +- [x] Agnes 完成态兼容 2.5 的 `metadata.url` 与 V2.0 的顶层 `url`;前端 provider 和后端协议专项测试均已覆盖。 - [ ] 使用真实 Agnes API Key 分别验证文生视频、单张首帧、三张图片 reference、视频/音频 reference;确认 `metadata.url` 可播放并且失败信息可见。 +- [ ] 使用 Agnes Video V2.0 完成一次真实任务,确认顶层 `url` 能进入视频资源保存流程,不再报“没有 metadata.url”。 - [ ] 在系统渠道中验证 4 秒与 12 秒边界、720P/960P/2K、横竖画幅,以及 2.5 Flash 的 720P/最多 5 图/不支持视频限制。 +## 画布生成按钮价格 + +- [x] 系统渠道价格按当前 operation、分辨率、时长、图片数量、质量和画幅匹配价格档,并按图片数量或视频秒数计算本次积分;逻辑前台模型继续使用后端报价。 +- [x] 画布生成按钮改为价格与圆形生成操作一体式胶囊,保留完整无障碍标签;价格匹配专项测试与前端生产构建通过。 +- [ ] 在画布中分别选择按次图片模型、按秒视频模型和前台逻辑模型,确认修改数量、时长、分辨率和参考素材后价格立即更新且与实际账单一致。 +- [ ] 在浅色/深色主题及生成中、禁用状态下确认胶囊价格可读、圆形操作区清晰且不挤压模型与参数控件。 + ## 系统渠道模型 Logo - [x] 后端保存系统渠道模型 Logo,并通过脱敏模型目录下发;前端类型检查与生产构建通过。 @@ -282,3 +291,13 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 在窄窗口检查摆场、姿态、动画、摄影机四种模式:标题栏可横向到达,底部属性面板可滚动,时间轴与引导不互相遮挡。 用户确认上述场景后,再将对应条目移动到功能文档的已验证范围。 + +## 模型请求失败日志完整性 + +模型请求日志已补齐 HTTP 失败关联、错误搜索与请求前失败兜底;以下场景仍需要在管理后台由用户确认: + +- [x] HTTP 非 2xx 日志同时保留节点使用的中文错误归类和上游原始错误详情。 +- [x] API 调用日志关键词搜索覆盖 `errorCode` 与 `error`,可按节点中文提示、上游错误或错误码定位。 +- [x] 参数校验、协议构造、路由准备等请求尚未发出的最终失败会补记 `request_not_sent`,且不保存任务输入、API Key 或请求正文。 +- [x] 已有真实 HTTP 调用日志的任务不会重复补记请求前失败日志。 +- [ ] 在画布分别触发一次成功请求、一次上游 400 和一次本地参数校验失败,确认 `/admin/logs` 均能看到对应记录,并可按节点错误文案搜索。 diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 09599737d..06e31930b 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -7,6 +7,7 @@ import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; +import { modelQuoteRequest } from "@/lib/model-pricing"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; @@ -26,6 +27,7 @@ import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins import { createPluginHostContext } from "@/services/plugin-host"; import { usePluginStore } from "@/stores/use-plugin-store"; import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references"; +import { quoteLogicalModel } from "@/services/api/logical-models"; export type CanvasNodeGenerationMode = CanvasGenerationMode; @@ -111,13 +113,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi }, [globalConfig, promptOptimizerEnabled, promptOptimizerInstallation]); const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const priceChannel = resolveModelChannel(config, config.model); - const credits = requestCreditCost({ + const configuredCredits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? generationCount : 1, seconds: mode === "video" ? config.videoSeconds : 1, + capability: mode, + config, + requirements, }); + const quoteRequest = modelQuoteRequest(config, config.model, mode, requirements); + const quoteRequestKey = JSON.stringify(quoteRequest || null); + const [quotedCredits, setQuotedCredits] = useState(null); + const credits = quotedCredits ?? configuredCredits; const activeReferenceCount = activeReferences.length; const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0; @@ -159,6 +168,23 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi setManualExpandedPromptHeight(null); }, [node.id]); + useEffect(() => { + if (!creditsEnabled || !quoteRequest) { + setQuotedCredits(null); + return; + } + const controller = new AbortController(); + setQuotedCredits(null); + quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal) + .then(({ quote }) => setQuotedCredits(quote.amountMicrocredits / 1_000_000)) + .catch(() => { + if (!controller.signal.aborted) setQuotedCredits(null); + }); + return () => controller.abort(); + // quoteRequestKey captures the full normalized request without retriggering on object identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [creditsEnabled, quoteRequestKey]); + const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]); const updatePrompt = (value: string) => { @@ -270,12 +296,12 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const renderSubmitButton = (expanded: boolean) => { const showCost = creditsEnabled && credits !== null; - const formattedCredits = credits?.toLocaleString(); + const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成"; return ( ); diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 3acbfcc42..9822a3080 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -4,14 +4,14 @@ import { Popover } from "antd"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities"; -import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; -import { normalizeVideoResolution } from "@/lib/video-generation-options"; +import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection } from "@/lib/model-pricing"; +import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { cn } from "@/lib/utils"; import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { ModelLogo } from "@/components/model-logo"; -import { quoteLogicalModel, type LogicalModelQuote, type ModelRequestIntent } from "@/services/api/logical-models"; +import { quoteLogicalModel, type LogicalModelQuote } from "@/services/api/logical-models"; type ModelPickerProps = { config: AiConfig; @@ -396,48 +396,6 @@ function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredM return channel.scope === "system" ? displayName : `${displayName}(${channel.name})`; } -function priceTiersForCurrentSelection( - tiers: NonNullable[number]["logicalPriceTiers"]>, - capability: ModelCapability | undefined, - config: AiConfig, - requirements?: ModelRequirements, -) { - const requested: Record = {}; - if (capability === "video") { - const imageCount = (requirements?.input?.imageCount || 0) + (requirements?.input?.characterCount || 0); - if (imageCount > 0) requested.imageCount = String(imageCount); - const resolution = normalizeTierResolution(config.vquality); - if (resolution !== "*") requested.vquality = resolution; - const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0)); - if (seconds > 0) requested.videoSeconds = String(seconds); - } - if (capability === "image") { - if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase(); - if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase(); - } - let bestScore = -1; - let matched: typeof tiers = []; - for (const tier of tiers) { - const selector = tier.selector || {}; - const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*"); - if (conditions.some(([key, value]) => requested[key] !== value)) continue; - const score = conditions.length; - if (score > bestScore) { - bestScore = score; - matched = [tier]; - } else if (score === bestScore) { - matched.push(tier); - } - } - return matched; -} - -function normalizeTierResolution(value: string) { - const raw = String(value || "").trim(); - if (!raw || raw === "*") return "*"; - return `${normalizeVideoResolution(raw)}p`; -} - function channelTierPriceSummary( visibleTiers: NonNullable[number]["logicalPriceTiers"]>, allTiers: NonNullable[number]["logicalPriceTiers"]>, @@ -533,31 +491,6 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | ); } -function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined { - if (!capability || !value) return undefined; - const channel = resolveModelChannel(config, value); - if (channel.scope !== "system") return undefined; - const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value)); - if (!cost?.logicalModelId) return undefined; - const input = requirements?.input; - const intent: ModelRequestIntent = { - capability, - operation: requirements?.videoOperation, - inputs: { - image: (input?.imageCount || 0) + (input?.characterCount || 0), - video: input?.videoCount || 0, - audio: input?.audioCount || 0, - }, - options: { - ...modelRequestOptions(config, capability), - ...(requirements?.options || {}), - ...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}), - ...(requirements?.imageSize ? { size: requirements.imageSize } : {}), - }, - }; - return { logicalModelID: cost.logicalModelId, intent }; -} - function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } { const name = modelOptionName(model).toLowerCase(); if (capability === "image") { diff --git a/web/src/constant/credits.tsx b/web/src/constant/credits.tsx index e5567385a..c86398dfc 100644 --- a/web/src/constant/credits.tsx +++ b/web/src/constant/credits.tsx @@ -1,6 +1,8 @@ import type { ComponentProps } from "react"; import { Coins } from "lucide-react"; +export { requestCreditCost } from "@/lib/model-pricing"; + export function CreditSymbol({ className, ...props }: ComponentProps<"span">) { return ( @@ -9,31 +11,6 @@ export function CreditSymbol({ className, ...props }: ComponentProps<"span">) { ); } -export type ModelCreditCost = { - model: string; - pricePolicy?: "channel" | "unified"; - billingMode: "fixed_request" | "per_second" | "token"; - unitPriceMicrocredits: number; -}; - -function modelCreditCost(modelCosts: ModelCreditCost[] | undefined, model: string) { - return modelCosts?.find((item) => item.model === model) || null; -} - export function formatCredits(value: number, maximumFractionDigits = 6) { return (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits }); } - -export function requestCreditCost(options: { channelMode: string; modelCosts?: ModelCreditCost[]; model: string; count?: string | number; seconds?: string | number }) { - if (options.channelMode !== "remote") return null; - const cost = modelCreditCost(options.modelCosts, options.model); - if (!cost) return null; - // 跟随供应价格的前台模型只有后端完成能力路由后才能确定价格,前端不展示误导性预估。 - if (cost.pricePolicy === "channel") return null; - // Token 订单由服务端按请求体预授权并在 usage 返回后结算,前端不展示无依据的固定价格。 - if (cost.billingMode === "token") return null; - const quantity = cost.billingMode === "per_second" - ? Math.max(1, Math.floor(Math.abs(Number(options.seconds)) || 1)) - : Math.max(1, Math.floor(Math.abs(Number(options.count)) || 1)); - return (cost.unitPriceMicrocredits / 1_000_000) * quantity; -} diff --git a/web/src/lib/model-pricing.ts b/web/src/lib/model-pricing.ts new file mode 100644 index 000000000..29babbcff --- /dev/null +++ b/web/src/lib/model-pricing.ts @@ -0,0 +1,130 @@ +import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection"; +import { normalizeVideoResolution } from "@/lib/video-generation-options"; +import type { ModelRequestIntent } from "@/services/api/logical-models"; +import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; + +export type ModelPriceTier = NonNullable[number]["logicalPriceTiers"]>[number]; + +type ModelCreditCost = { + model: string; + pricePolicy?: "channel" | "unified"; + billingMode: "fixed_request" | "per_second" | "token"; + unitPriceMicrocredits: number; + logicalPriceTiers?: ModelPriceTier[]; +}; + +export function requestCreditCost(options: { + channelMode: string; + modelCosts?: ModelCreditCost[]; + model: string; + count?: string | number; + seconds?: string | number; + capability?: ModelCapability; + config?: AiConfig; + requirements?: ModelRequirements; +}) { + if (options.channelMode !== "remote") return null; + const cost = options.modelCosts?.find((item) => item.model === options.model) || null; + if (!cost) return null; + if (cost.pricePolicy === "channel") { + if (!options.config) return null; + const tiers = priceTiersForCurrentSelection(cost.logicalPriceTiers || [], options.capability, options.config, options.requirements); + if (!tiers.length) return null; + const first = tiers[0]; + if (!first || first.billingMode === "token") return null; + // 同一精确规格可能来自多个逻辑路由;只有价格一致时才可在客户端安全展示。 + if (tiers.some((tier) => tier.billingMode !== first.billingMode || tier.unitPriceMicrocredits !== first.unitPriceMicrocredits)) return null; + return creditAmount(first.billingMode, first.unitPriceMicrocredits, options.count, options.seconds); + } + // Token 订单由服务端按请求体预授权并在 usage 返回后结算,前端不展示无依据的固定价格。 + if (cost.billingMode === "token") return null; + return creditAmount(cost.billingMode, cost.unitPriceMicrocredits, options.count, options.seconds); +} + +export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capability: ModelCapability | undefined, config: AiConfig, requirements?: ModelRequirements) { + const requested = priceSelectorForRequest(capability, config, requirements); + let bestScore = -1; + let matched: ModelPriceTier[] = []; + for (const tier of tiers) { + const selector = priceSelectorForTier(tier); + const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*"); + if (conditions.some(([key, value]) => requested[key] !== value)) continue; + const score = conditions.length; + if (score > bestScore) { + bestScore = score; + matched = [tier]; + } else if (score === bestScore) { + matched.push(tier); + } + } + return matched; +} + +export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined { + if (!capability || !value) return undefined; + const channel = resolveModelChannel(config, value); + if (channel.scope !== "system") return undefined; + const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value)); + if (!cost?.logicalModelId) return undefined; + const input = requirements?.input; + const intent: ModelRequestIntent = { + capability, + operation: capability === "video" && input ? resolveVideoOperation(input, requirements?.videoOperation) : requirements?.videoOperation, + inputs: { + image: (input?.imageCount || 0) + (input?.characterCount || 0), + video: input?.videoCount || 0, + audio: input?.audioCount || 0, + }, + options: { + ...modelRequestOptions(config, capability), + ...(requirements?.options || {}), + ...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}), + ...(requirements?.imageSize ? { size: requirements.imageSize } : {}), + }, + }; + return { logicalModelID: cost.logicalModelId, intent }; +} + +function creditAmount(billingMode: "fixed_request" | "per_second", unitPriceMicrocredits: number, count?: string | number, seconds?: string | number) { + const quantity = billingMode === "per_second" + ? Math.max(1, Math.floor(Math.abs(Number(seconds)) || 1)) + : Math.max(1, Math.floor(Math.abs(Number(count)) || 1)); + return (unitPriceMicrocredits / 1_000_000) * quantity; +} + +function priceSelectorForRequest(capability: ModelCapability | undefined, config: AiConfig, requirements?: ModelRequirements) { + const requested: Record = {}; + if (capability === "video") { + const input = requirements?.input; + if (input) { + const imageCount = (input.imageCount || 0) + (input.characterCount || 0); + requested.operation = input.videoCount > 0 ? "video_to_video" : imageCount > 0 ? "image_to_video" : resolveVideoOperation(input, requirements?.videoOperation); + if (imageCount > 0) requested.imageCount = String(imageCount); + } + const resolution = normalizeTierResolution(config.vquality); + if (resolution !== "*") requested.vquality = resolution; + const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0)); + if (seconds > 0) requested.videoSeconds = String(seconds); + } + if (capability === "image") { + if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase(); + if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase(); + } + return requested; +} + +function priceSelectorForTier(tier: ModelPriceTier) { + const selector = { ...(tier.selector || {}) }; + if (!Object.keys(selector).length) { + const resolution = normalizeTierResolution(tier.resolution); + if (resolution !== "*") selector.vquality = resolution; + if (tier.videoSeconds > 0) selector.videoSeconds = String(tier.videoSeconds); + } + return selector; +} + +export function normalizeTierResolution(value: string) { + const raw = String(value || "").trim(); + if (!raw || raw === "*") return "*"; + return `${normalizeVideoResolution(raw)}p`; +} diff --git a/web/src/services/api/video-provider-agnes.ts b/web/src/services/api/video-provider-agnes.ts index 3d903ac8f..eae4db660 100644 --- a/web/src/services/api/video-provider-agnes.ts +++ b/web/src/services/api/video-provider-agnes.ts @@ -11,6 +11,7 @@ type AgnesTaskResponse = { task_id?: string; video_id?: string; status?: string; + url?: string; metadata?: { url?: string } | null; error?: { message?: string; detail?: string; code?: string } | null; message?: string; @@ -64,8 +65,8 @@ export async function pollAgnesVideoTask(deps: VideoProviderDeps, config: Resolv const state = deps.response.unwrapEnvelope(raw, "Agnes 接口没有返回任务状态"); const status = String(state.status || "").trim().toLowerCase(); if (status === "completed" || status === "succeeded" || status === "success" || status === "done") { - const url = String(state.metadata?.url || "").trim(); - if (!url) return { status: "failed", error: "Agnes 任务已完成但没有返回 metadata.url" }; + const url = String(state.metadata?.url || state.url || "").trim(); + if (!url) return { status: "failed", error: "Agnes 任务已完成但没有返回视频 URL" }; return { status: "completed", result: await deps.response.videoResultFromUrl(url, options) }; } if (status === "failed" || status === "cancelled" || status === "canceled") return { status: "failed", error: agnesFailureMessage(state) }; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 17065980c..e98d93d26 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -8818,6 +8818,22 @@ color: var(--canvas-composer-submit-action-fg); } + .canvas-node-composer-submit.canvas-node-composer-submit-canvas { + min-width: var(--space-8) !important; + height: var(--space-8) !important; + gap: var(--space-1-half) !important; + padding: var(--space-half) !important; + font-size: var(--fs-caption) !important; + } + + .canvas-node-composer-submit.canvas-node-composer-submit-canvas.has-cost { + padding-left: var(--space-2-half) !important; + } + + .canvas-node-composer-submit-canvas .canvas-node-composer-submit-cost > :first-child { + font-size: var(--fs-caption); + } + .canvas-node-composer-resize-handle { display: grid; width: 100%; diff --git a/web/test/agnes-video.test.ts b/web/test/agnes-video.test.ts index 7135d4669..01cb73896 100644 --- a/web/test/agnes-video.test.ts +++ b/web/test/agnes-video.test.ts @@ -133,6 +133,14 @@ describe("Agnes Video 2.5 request contract", () => { expect(state).toEqual({ status: "completed", result: { url: "https://cdn.example.com/result.mp4", mimeType: "video/mp4" } }); }); + test("accepts the top-level result url returned by Agnes Video V2.0", async () => { + const config = resolveModelRequestConfig(configForAgnes("agnes-video-v2.0"), "agnes::agnes-video-v2.0"); + const { deps } = providerDeps(config, { polled: { id: "video-1", status: "completed", url: "https://cdn.example.com/v20-result.mp4" } }); + const state = await pollAgnesVideoTask(deps, config, { id: "video-1", provider: "agnes", model: "agnes::agnes-video-v2.0" }); + + expect(state).toEqual({ status: "completed", result: { url: "https://cdn.example.com/v20-result.mp4", mimeType: "video/mp4" } }); + }); + test("keeps system-channel polls inside the proxy while preserving the root path", () => { const config = { ...resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5"), baseUrl: "/api/system-agnes" }; expect(agnesPollUrl(config, "video/1", "agnes-video-2.5")).toBe("/api/system-agnes/agnesapi?video_id=video%2F1&model_name=agnes-video-2.5"); diff --git a/web/test/model-pricing.test.ts b/web/test/model-pricing.test.ts new file mode 100644 index 000000000..88eccdf99 --- /dev/null +++ b/web/test/model-pricing.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; + +import { modelQuoteRequest, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing"; +import type { ModelRequirements } from "../src/lib/model-selection"; +import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store"; + +function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number }> }) { + const capability = input.capability || "video"; + const model = capability === "video" ? "agnes-video-2.5" : "image-model"; + const channel = createModelChannel({ + id: "system-channel", + name: "系统渠道", + scope: "system", + baseUrl: "/api/system-channel", + apiKey: "system", + apiFormat: "openai", + models: [model], + modelCosts: [{ + model, + capability, + pricePolicy: "channel", + billingMode: input.tiers[0]?.billingMode || "fixed_request", + unitPriceMicrocredits: input.tiers[0]?.unitPriceMicrocredits || 0, + logicalModelId: input.logicalModelId, + logicalPriceTiers: input.tiers.map((tier) => ({ + ...tier, + resolution: tier.selector.vquality || "*", + videoSeconds: Number(tier.selector.videoSeconds || 0), + inputTokenPriceMicrocredits: 0, + outputTokenPriceMicrocredits: 0, + cachedTokenPriceMicrocredits: 0, + })), + }], + }); + return normalizeConfigSnapshot({ + config: { + ...defaultConfig, + channels: [channel], + model: `${channel.id}::${model}`, + imageModel: capability === "image" ? `${channel.id}::${model}` : defaultConfig.imageModel, + videoModel: capability === "video" ? `${channel.id}::${model}` : defaultConfig.videoModel, + quality: "high", + size: "16:9", + vquality: "720P", + videoSeconds: "5", + count: "3", + }, + }).config; +} + +const textVideoRequirements: ModelRequirements = { + capability: "video", + input: { textCount: 1, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 }, + videoSeconds: "5", + options: { size: "16:9", vquality: "720", videoSeconds: 5 }, +}; + +describe("model request pricing", () => { + test("matches the current Agnes resolution and duration tier and totals per-second credits", () => { + const config = systemConfig({ + tiers: [ + { selector: { operation: "text_to_video", vquality: "720p", videoSeconds: "5" }, billingMode: "per_second", unitPriceMicrocredits: 25_000 }, + { selector: { operation: "text_to_video", vquality: "960p", videoSeconds: "5" }, billingMode: "per_second", unitPriceMicrocredits: 40_000 }, + { selector: { operation: "image_to_video", vquality: "720p", videoSeconds: "5", imageCount: "1" }, billingMode: "per_second", unitPriceMicrocredits: 30_000 }, + ], + }); + const channel = resolveModelChannel(config, config.model); + + expect(requestCreditCost({ + channelMode: "remote", + modelCosts: channel.modelCosts, + model: "agnes-video-2.5", + capability: "video", + config, + requirements: textVideoRequirements, + seconds: "5", + })).toBe(0.125); + }); + + test("uses reference count and operation to avoid a text-video price tier", () => { + const config = systemConfig({ + tiers: [ + { selector: { operation: "text_to_video", vquality: "720p", videoSeconds: "5" }, billingMode: "per_second", unitPriceMicrocredits: 25_000 }, + { selector: { operation: "image_to_video", vquality: "720p", videoSeconds: "5", imageCount: "1" }, billingMode: "per_second", unitPriceMicrocredits: 30_000 }, + ], + }); + const requirements: ModelRequirements = { + ...textVideoRequirements, + input: { ...textVideoRequirements.input!, imageCount: 1 }, + }; + const matched = priceTiersForCurrentSelection(resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!, "video", config, requirements); + + expect(matched).toHaveLength(1); + expect(matched[0]?.unitPriceMicrocredits).toBe(30_000); + }); + + test("multiplies fixed image request pricing by output count", () => { + const config = systemConfig({ + capability: "image", + tiers: [{ selector: { quality: "high", size: "16:9" }, billingMode: "fixed_request", unitPriceMicrocredits: 10_000 }], + }); + const channel = resolveModelChannel(config, config.model); + + expect(requestCreditCost({ + channelMode: "remote", + modelCosts: channel.modelCosts, + model: "image-model", + capability: "image", + config, + requirements: { capability: "image" }, + count: 3, + })).toBe(0.03); + }); + + test("builds a logical-model quote using the normalized current request", () => { + const config = systemConfig({ + logicalModelId: "logical-video-1", + tiers: [{ selector: {}, billingMode: "per_second", unitPriceMicrocredits: 25_000 }], + }); + const quote = modelQuoteRequest(config, config.model, "video", textVideoRequirements); + + expect(quote).toMatchObject({ + logicalModelID: "logical-video-1", + intent: { + capability: "video", + operation: "text_to_video", + inputs: { image: 0, video: 0, audio: 0 }, + options: { vquality: "720", videoSeconds: 5 }, + }, + }); + }); +});