feat(*): 模型生成链路 - 完善 Agnes 结果兼容、价格展示与请求日志追踪
This commit is contained in:
1 parent
4ee5b630ed
commit
ce448c26cb
20 files changed
+671
-108
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 输入。
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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` 均能看到对应记录,并可按节点错误文案搜索。
|
||||
@@ -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<number | null>(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 (
|
||||
<Button
|
||||
type="text"
|
||||
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
|
||||
className={`canvas-node-composer-submit canvas-node-composer-submit-canvas ${showCost ? "has-cost" : ""}`}
|
||||
disabled={isRunning || isSubmitDisabled}
|
||||
style={
|
||||
{
|
||||
@@ -295,7 +321,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
</span>
|
||||
) : null}
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>
|
||||
{isRunning ? <LoaderCircle className="size-3 animate-spin motion-reduce:animate-none" /> : <ArrowUp className="size-3" />}
|
||||
{isRunning ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : <ArrowUp className="size-3.5" strokeWidth={2.4} />}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
|
||||
capability: ModelCapability | undefined,
|
||||
config: AiConfig,
|
||||
requirements?: ModelRequirements,
|
||||
) {
|
||||
const requested: Record<string, string> = {};
|
||||
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<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
|
||||
allTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[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") {
|
||||
|
||||
@@ -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 (
|
||||
<span {...props} className={`inline-flex items-center justify-center ${className || ""}`}>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<NonNullable<AiConfig["channels"][number]["modelCosts"]>[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<string, string> = {};
|
||||
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`;
|
||||
}
|
||||
@@ -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) };
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, string>; 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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user