feat: add parameter preprocessing audit trail
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestParamProcessorOmniFiltersUnsupportedVideoAndAudioContent(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "可灵O1",
|
||||
"prompt": "edit the source video",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "edit the source video"},
|
||||
map[string]any{"type": "video_url", "role": "video_base", "video_url": map[string]any{"url": "https://example.com/base.mp4", "refer_type": "base"}},
|
||||
map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": "https://example.com/ref.mp4", "refer_type": "feature"}},
|
||||
map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": "https://example.com/ref.mp3"}},
|
||||
},
|
||||
}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelType: "omni_video",
|
||||
Capabilities: map[string]any{
|
||||
"omni_video": map[string]any{
|
||||
"supported_modes": []any{"video_edit"},
|
||||
"max_videos": 1,
|
||||
"input_audio": false,
|
||||
"max_audios": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||
processed := result.Body
|
||||
content := contentItems(processed["content"])
|
||||
if len(content) != 2 {
|
||||
t.Fatalf("expected text plus one video item, got %+v", content)
|
||||
}
|
||||
if stringFromAny(content[1]["role"]) != "video_base" || isAudioContent(content[1]) {
|
||||
t.Fatalf("unexpected retained content: %+v", content)
|
||||
}
|
||||
for _, item := range content {
|
||||
if isAudioContent(item) || stringFromAny(item["role"]) == "reference_video" {
|
||||
t.Fatalf("unsupported content was not filtered: %+v", content)
|
||||
}
|
||||
}
|
||||
if !result.Log.Changed || len(result.Log.Changes) < 2 {
|
||||
t.Fatalf("expected preprocessing log with filtered video and audio changes, got %+v", result.Log)
|
||||
}
|
||||
if result.Log.Input["content"] == nil || result.Log.Output["content"] == nil {
|
||||
t.Fatalf("preprocessing log should keep actual input and converted output: %+v", result.Log)
|
||||
}
|
||||
foundAudioReason := false
|
||||
for _, change := range result.Log.Changes {
|
||||
if change.Path == "content[3]" && change.CapabilityPath == "capabilities.omni_video.input_audio" {
|
||||
foundAudioReason = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundAudioReason {
|
||||
t.Fatalf("expected audio filtering reason to reference omni_video.input_audio, got %+v", result.Log.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamProcessorOmniFiltersConvenienceReferenceFields(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "可灵V3多模态",
|
||||
"prompt": "text only",
|
||||
"reference_video": "https://example.com/ref.mp4",
|
||||
"reference_audio": "https://example.com/ref.mp3",
|
||||
}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelType: "omni_video",
|
||||
Capabilities: map[string]any{
|
||||
"omni_video": map[string]any{
|
||||
"supported_modes": []any{"text_to_video"},
|
||||
"max_videos": 0,
|
||||
"input_audio": false,
|
||||
"max_audios": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||
processed := result.Body
|
||||
content := contentItems(processed["content"])
|
||||
if len(content) != 1 || stringFromAny(content[0]["type"]) != "text" {
|
||||
t.Fatalf("expected only text content, got %+v", content)
|
||||
}
|
||||
for _, key := range []string{"reference_video", "reference_audio"} {
|
||||
if processed[key] != nil {
|
||||
t.Fatalf("%s should be removed when capability rejects it: %+v", key, processed)
|
||||
}
|
||||
}
|
||||
if len(result.Log.Changes) == 0 {
|
||||
t.Fatalf("expected convenience-field filtering to be logged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamProcessorOmniCapabilityLogUsesActualCapabilityKey(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "Omni",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "animate"},
|
||||
map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": "https://example.com/ref.mp3"}},
|
||||
},
|
||||
}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelType: "omni",
|
||||
Capabilities: map[string]any{
|
||||
"omni": map[string]any{
|
||||
"input_audio": false,
|
||||
"max_audios": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||
for _, change := range result.Log.Changes {
|
||||
if change.Path == "content[1]" && change.CapabilityPath == "capabilities.omni.input_audio" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected log to reference capabilities.omni.input_audio, got %+v", result.Log.Changes)
|
||||
}
|
||||
|
||||
func TestParamProcessorVideoCapabilitiesNormalizeAndFilter(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "Seedance",
|
||||
"duration": 13,
|
||||
"aspect_ratio": "4:3",
|
||||
"resolution": "1080p",
|
||||
"audio": true,
|
||||
"output_audio": true,
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "animate it"},
|
||||
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/first.png"}},
|
||||
map[string]any{"type": "image_url", "role": "last_frame", "image_url": map[string]any{"url": "https://example.com/last.png"}},
|
||||
map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": "https://example.com/ref.mp3"}},
|
||||
},
|
||||
}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelType: "image_to_video",
|
||||
Capabilities: map[string]any{
|
||||
"image_to_video": map[string]any{
|
||||
"aspect_ratio_allowed": []any{"16:9", "1:1"},
|
||||
"duration_options": []any{4, 8, 12},
|
||||
"input_first_last_frame": false,
|
||||
"input_audio": false,
|
||||
"output_audio": false,
|
||||
"max_images_for_last_frame": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||
processed := result.Body
|
||||
if processed["duration"] != float64(12) && processed["duration"] != 12 {
|
||||
t.Fatalf("duration should be snapped to 12, got %+v", processed["duration"])
|
||||
}
|
||||
if processed["aspect_ratio"] != "16:9" {
|
||||
t.Fatalf("aspect_ratio should fall back to first allowed value, got %+v", processed["aspect_ratio"])
|
||||
}
|
||||
if processed["audio"] != nil || processed["output_audio"] != nil {
|
||||
t.Fatalf("output audio flags should be removed: %+v", processed)
|
||||
}
|
||||
for _, item := range contentItems(processed["content"]) {
|
||||
if stringFromAny(item["role"]) == "last_frame" || isAudioContent(item) {
|
||||
t.Fatalf("unsupported content remained: %+v", processed["content"])
|
||||
}
|
||||
}
|
||||
foundDuration := false
|
||||
for _, change := range result.Log.Changes {
|
||||
if change.Path == "duration" && change.CapabilityPath == "capabilities.image_to_video.duration_options" {
|
||||
foundDuration = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundDuration {
|
||||
t.Fatalf("expected duration adjustment to reference duration_options, got %+v", result.Log.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamProcessorImageResolutionAndOutputCount(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "即梦V4.0",
|
||||
"prompt": "draw",
|
||||
"size": "2K",
|
||||
"n": 8,
|
||||
}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelType: "image_generate",
|
||||
Capabilities: map[string]any{
|
||||
"image_generate": map[string]any{
|
||||
"output_multiple_images": true,
|
||||
"output_max_images_count": 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
processed := preprocessRequest("images.generations", body, candidate)
|
||||
if processed["resolution"] != "2K" {
|
||||
t.Fatalf("size resolution should be copied to resolution, got %+v", processed)
|
||||
}
|
||||
if processed["n"] != 4 {
|
||||
t.Fatalf("image count should be capped to 4, got %+v", processed["n"])
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,13 @@ type EstimateResult struct {
|
||||
}
|
||||
|
||||
func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
|
||||
body = normalizeRequest(kind, body)
|
||||
candidates, err := s.store.ListModelCandidates(ctx, model, modelTypeFromKind(kind, body), user)
|
||||
if err != nil {
|
||||
return EstimateResult{}, err
|
||||
}
|
||||
candidate := candidates[0]
|
||||
body = preprocessRequest(kind, body, candidate)
|
||||
return EstimateResult{
|
||||
Items: s.estimatedBillings(ctx, user, kind, body, candidate),
|
||||
Resolver: "effective-pricing-v1",
|
||||
|
||||
@@ -243,6 +243,9 @@ func summarizeAttempts(attempts []store.TaskAttempt) []map[string]any {
|
||||
if trace, ok := attempt.Metrics["trace"]; ok {
|
||||
item["trace"] = trace
|
||||
}
|
||||
if preprocessing, ok := attempt.Metrics["parameterPreprocessingSummary"]; ok {
|
||||
item["parameterPreprocessingSummary"] = preprocessing
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
|
||||
@@ -96,11 +96,24 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, err
|
||||
}
|
||||
firstCandidateBody := body
|
||||
normalizedModelType := modelType
|
||||
var firstPreprocessing parameterPreprocessingLog
|
||||
if len(candidates) > 0 {
|
||||
estimatedBillings := s.estimatedBillings(ctx, user, task.Kind, body, candidates[0])
|
||||
preprocessing := preprocessRequestWithLog(task.Kind, body, candidates[0])
|
||||
firstCandidateBody = preprocessing.Body
|
||||
firstPreprocessing = preprocessing.Log
|
||||
normalizedModelType = candidates[0].ModelType
|
||||
if err := s.store.MarkTaskRunning(ctx, task.ID, candidates[0].ModelType, firstCandidateBody); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
estimatedBillings := s.estimatedBillings(ctx, user, task.Kind, firstCandidateBody, candidates[0])
|
||||
if err := s.ensureWalletBalance(ctx, user, estimatedBillings); err != nil {
|
||||
if errors.Is(err, store.ErrInsufficientWalletBalance) {
|
||||
failed, finishErr := s.failTask(ctx, task.ID, "insufficient_balance", err.Error(), task.RunMode == "simulation", err)
|
||||
if logErr := s.recordTaskParameterPreprocessing(ctx, task.ID, "", 0, candidates[0], firstPreprocessing); logErr != nil {
|
||||
return Result{}, logErr
|
||||
}
|
||||
failed, finishErr := s.failTask(ctx, task.ID, "insufficient_balance", err.Error(), task.RunMode == "simulation", err, parameterPreprocessingMetrics(firstPreprocessing))
|
||||
if finishErr != nil {
|
||||
return Result{}, finishErr
|
||||
}
|
||||
@@ -109,7 +122,7 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
if err := s.emit(ctx, task.ID, "task.progress", "running", "normalizing", 0.15, "request normalized", map[string]any{"modelType": modelType}, task.RunMode == "simulation"); err != nil {
|
||||
if err := s.emit(ctx, task.ID, "task.progress", "running", "normalizing", 0.15, "request normalized", map[string]any{"modelType": normalizedModelType}, task.RunMode == "simulation"); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
@@ -122,6 +135,7 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
||||
attemptNo := task.AttemptCount
|
||||
var lastErr error
|
||||
var lastCandidate store.RuntimeModelCandidate
|
||||
var lastPreprocessing *parameterPreprocessingLog
|
||||
candidatesLoop:
|
||||
for index, candidate := range candidates {
|
||||
if index >= maxPlatforms {
|
||||
@@ -132,11 +146,16 @@ candidatesLoop:
|
||||
var candidateErr error
|
||||
for clientAttempt := 1; clientAttempt <= clientAttempts; clientAttempt++ {
|
||||
nextAttemptNo := attemptNo + 1
|
||||
response, err := s.runCandidate(ctx, task, user, body, candidate, nextAttemptNo, onDelta)
|
||||
preprocessing := preprocessRequestWithLog(task.Kind, body, candidate)
|
||||
preprocessingLog := preprocessing.Log
|
||||
lastPreprocessing = &preprocessingLog
|
||||
candidateBody := preprocessing.Body
|
||||
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta)
|
||||
if err == nil {
|
||||
attemptNo = nextAttemptNo
|
||||
billings := s.billings(ctx, user, task.Kind, body, candidate, response, isSimulation(task, candidate))
|
||||
record := buildSuccessRecord(task, user, body, candidate, response, billings, isSimulation(task, candidate))
|
||||
billings := s.billings(ctx, user, task.Kind, candidateBody, candidate, response, isSimulation(task, candidate))
|
||||
record := buildSuccessRecord(task, user, candidateBody, candidate, response, billings, isSimulation(task, candidate))
|
||||
record.Metrics = mergeMetrics(record.Metrics, parameterPreprocessingMetrics(preprocessing.Log))
|
||||
record.Metrics = s.withAttemptHistory(ctx, task.ID, record.Metrics)
|
||||
finished, finishErr := s.store.FinishTaskSuccess(ctx, store.FinishTaskSuccessInput{
|
||||
TaskID: task.ID,
|
||||
@@ -305,15 +324,20 @@ candidatesLoop:
|
||||
}
|
||||
return Result{Task: queued, Output: queued.Result}, &TaskQueuedError{Delay: delay}
|
||||
}
|
||||
failed, err := s.failTask(ctx, task.ID, code, message, task.RunMode == "simulation", lastErr)
|
||||
extraMetrics := []map[string]any{}
|
||||
if lastPreprocessing != nil {
|
||||
extraMetrics = append(extraMetrics, parameterPreprocessingMetrics(*lastPreprocessing))
|
||||
}
|
||||
failed, err := s.failTask(ctx, task.ID, code, message, task.RunMode == "simulation", lastErr, extraMetrics...)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{Task: failed, Output: failed.Result}, lastErr
|
||||
}
|
||||
|
||||
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, candidate store.RuntimeModelCandidate, attemptNo int, onDelta clients.StreamDelta) (clients.Response, error) {
|
||||
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, attemptNo int, onDelta clients.StreamDelta) (clients.Response, error) {
|
||||
simulated := isSimulation(task, candidate)
|
||||
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
|
||||
reservations := s.rateLimitReservations(ctx, user, candidate, body)
|
||||
limitResult, err := s.store.ReserveRateLimits(ctx, task.ID, "", reservations)
|
||||
if err != nil {
|
||||
@@ -339,18 +363,30 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
Status: "running",
|
||||
Simulated: simulated,
|
||||
RequestSnapshot: body,
|
||||
Metrics: attemptMetrics(candidate, attemptNo, simulated),
|
||||
Metrics: baseAttemptMetrics,
|
||||
})
|
||||
if err != nil {
|
||||
return clients.Response{}, fmt.Errorf("create task attempt: %w", err)
|
||||
}
|
||||
if err := s.recordTaskParameterPreprocessing(ctx, task.ID, attemptID, attemptNo, candidate, preprocessing); err != nil {
|
||||
clientErr := &clients.ClientError{Code: "runtime_error", Message: err.Error(), Retryable: false}
|
||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||
AttemptID: attemptID,
|
||||
Status: "failed",
|
||||
Retryable: false,
|
||||
Metrics: mergeMetrics(baseAttemptMetrics, map[string]any{"error": err.Error(), "retryable": false, "trace": []any{failureTraceEntry(clientErr, false)}}),
|
||||
ErrorCode: clients.ErrorCode(clientErr),
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
return clients.Response{}, fmt.Errorf("record parameter preprocessing: %w", err)
|
||||
}
|
||||
if err := s.store.AttachRateLimitResultToAttempt(ctx, attemptID, limitResult); err != nil {
|
||||
clientErr := &clients.ClientError{Code: "runtime_error", Message: err.Error(), Retryable: false}
|
||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||
AttemptID: attemptID,
|
||||
Status: "failed",
|
||||
Retryable: false,
|
||||
Metrics: mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), map[string]any{"error": err.Error(), "retryable": false, "trace": []any{failureTraceEntry(clientErr, false)}}),
|
||||
Metrics: mergeMetrics(baseAttemptMetrics, map[string]any{"error": err.Error(), "retryable": false, "trace": []any{failureTraceEntry(clientErr, false)}}),
|
||||
ErrorCode: clients.ErrorCode(clientErr),
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
@@ -371,7 +407,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
AttemptID: attemptID,
|
||||
Status: "failed",
|
||||
Retryable: false,
|
||||
Metrics: mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), map[string]any{"error": err.Error(), "retryable": false, "trace": []any{failureTraceEntry(err, false)}}),
|
||||
Metrics: mergeMetrics(baseAttemptMetrics, map[string]any{"error": err.Error(), "retryable": false, "trace": []any{failureTraceEntry(err, false)}}),
|
||||
ErrorCode: clients.ErrorCode(err),
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
@@ -425,7 +461,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
responseDurationMS = 0
|
||||
}
|
||||
}
|
||||
metrics = mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), metrics)
|
||||
metrics = mergeMetrics(baseAttemptMetrics, metrics)
|
||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||
AttemptID: attemptID,
|
||||
Status: "failed",
|
||||
@@ -444,7 +480,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
}
|
||||
uploadedResult, err := s.uploadGeneratedAssets(ctx, response.Result)
|
||||
if err != nil {
|
||||
metrics := mergeMetrics(taskMetrics(task, user, body, candidate, response, simulated), map[string]any{
|
||||
metrics := mergeMetrics(taskMetrics(task, user, body, candidate, response, simulated), parameterPreprocessingMetrics(preprocessing), map[string]any{
|
||||
"error": err.Error(),
|
||||
"retryable": clients.IsRetryable(err),
|
||||
"trace": []any{failureTraceEntry(err, clients.IsRetryable(err))},
|
||||
@@ -480,7 +516,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
Status: "succeeded",
|
||||
RequestID: response.RequestID,
|
||||
Usage: usageToMap(response.Usage),
|
||||
Metrics: taskMetrics(task, user, body, candidate, response, simulated),
|
||||
Metrics: mergeMetrics(taskMetrics(task, user, body, candidate, response, simulated), parameterPreprocessingMetrics(preprocessing)),
|
||||
ResponseSnapshot: response.Result,
|
||||
ResponseStartedAt: response.ResponseStartedAt,
|
||||
ResponseFinishedAt: response.ResponseFinishedAt,
|
||||
@@ -491,6 +527,25 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordTaskParameterPreprocessing(ctx context.Context, taskID string, attemptID string, attemptNo int, candidate store.RuntimeModelCandidate, log parameterPreprocessingLog) error {
|
||||
_, err := s.store.CreateTaskParamPreprocessingLog(ctx, store.CreateTaskParamPreprocessingLogInput{
|
||||
TaskID: taskID,
|
||||
AttemptID: attemptID,
|
||||
AttemptNo: attemptNo,
|
||||
ModelType: log.ModelType,
|
||||
PlatformID: candidate.PlatformID,
|
||||
PlatformModelID: candidate.PlatformModelID,
|
||||
ClientID: candidate.ClientID,
|
||||
Changed: log.Changed,
|
||||
ChangeCount: len(log.Changes),
|
||||
ActualInput: log.Input,
|
||||
ConvertedOutput: log.Output,
|
||||
Changes: log.Changes,
|
||||
ModelSnapshot: log.Model,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) clientFor(candidate store.RuntimeModelCandidate, simulated bool) clients.Client {
|
||||
if simulated {
|
||||
return s.clients["simulation"]
|
||||
@@ -505,8 +560,12 @@ func (s *Service) clientFor(candidate store.RuntimeModelCandidate, simulated boo
|
||||
return s.clients["openai"]
|
||||
}
|
||||
|
||||
func (s *Service) failTask(ctx context.Context, taskID string, code string, message string, simulated bool, cause error) (store.GatewayTask, error) {
|
||||
func (s *Service) failTask(ctx context.Context, taskID string, code string, message string, simulated bool, cause error, extraMetrics ...map[string]any) (store.GatewayTask, error) {
|
||||
requestID, metrics, responseStartedAt, responseFinishedAt, responseDurationMS := failureMetrics(cause, simulated)
|
||||
if len(extraMetrics) > 0 {
|
||||
values := append([]map[string]any{metrics}, extraMetrics...)
|
||||
metrics = mergeMetrics(values...)
|
||||
}
|
||||
metrics = s.withAttemptHistory(ctx, taskID, metrics)
|
||||
failed, err := s.store.FinishTaskFailure(ctx, store.FinishTaskFailureInput{
|
||||
TaskID: taskID,
|
||||
@@ -589,6 +648,9 @@ func (s *Service) emit(ctx context.Context, taskID string, eventType string, sta
|
||||
}
|
||||
|
||||
func modelTypeFromKind(kind string, body map[string]any) string {
|
||||
if requested := requestedModelTypeFromBody(body); requested != "" {
|
||||
return requested
|
||||
}
|
||||
switch kind {
|
||||
case "chat.completions", "responses":
|
||||
return "text_generate"
|
||||
@@ -598,6 +660,9 @@ func modelTypeFromKind(kind string, body map[string]any) string {
|
||||
}
|
||||
return "image_generate"
|
||||
case "videos.generations":
|
||||
if videoRequestHasVideoOrAudioReference(body) {
|
||||
return "omni_video"
|
||||
}
|
||||
if videoRequestHasReferenceImage(body) {
|
||||
return "image_to_video"
|
||||
}
|
||||
@@ -607,6 +672,25 @@ func modelTypeFromKind(kind string, body map[string]any) string {
|
||||
}
|
||||
}
|
||||
|
||||
func requestedModelTypeFromBody(body map[string]any) string {
|
||||
for _, key := range []string{"modelType", "model_type", "capability", "capabilityType"} {
|
||||
value := strings.TrimSpace(stringFromMap(body, key))
|
||||
if isKnownModelType(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isKnownModelType(value string) bool {
|
||||
switch value {
|
||||
case "text_generate", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "omni_video", "omni":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func videoRequestHasReferenceImage(body map[string]any) bool {
|
||||
if body == nil {
|
||||
return false
|
||||
@@ -622,6 +706,23 @@ func videoRequestHasReferenceImage(body map[string]any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func videoRequestHasVideoOrAudioReference(body map[string]any) bool {
|
||||
if body == nil {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"video", "video_url", "videoUrl", "reference_video", "referenceVideo", "audio_url", "audioUrl", "reference_audio", "referenceAudio"} {
|
||||
if hasAnyString(body, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, item := range contentItems(body["content"]) {
|
||||
if isVideoContent(item) || isAudioContent(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTextGenerationKind(kind string) bool {
|
||||
return kind == "chat.completions" || kind == "responses"
|
||||
}
|
||||
@@ -692,10 +793,7 @@ func failoverTimeBudgetExceeded(start time.Time, maxDuration time.Duration) bool
|
||||
}
|
||||
|
||||
func normalizeRequest(kind string, body map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for key, value := range body {
|
||||
out[key] = value
|
||||
}
|
||||
out := cloneMap(body)
|
||||
if kind == "responses" && out["messages"] == nil && out["input"] != nil {
|
||||
out["messages"] = []any{map[string]any{"role": "user", "content": out["input"]}}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user