feat(*): 模型生成链路 - 完善 Agnes 结果兼容、价格展示与请求日志追踪

This commit is contained in:
ddCat committed 2026-08-28 06:53:57 +08:00
1 parent 2c02f5ef3d
commit bd0d0fd4fd
20 files changed
+671 -108

No files matched your search

+20 -3
View File
@@ -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)
}
}
+11
View File
@@ -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")