Restore standard MiniMax resolutions and client mapping
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -157,9 +158,147 @@ func minimaxSpec() providerTaskSpec {
|
|||||||
StatusPaths: []string{"status"},
|
StatusPaths: []string{"status"},
|
||||||
SuccessStatuses: []string{"success"},
|
SuccessStatuses: []string{"success"},
|
||||||
FailureStatuses: []string{"failed", "expired"},
|
FailureStatuses: []string{"failed", "expired"},
|
||||||
|
DefaultSubmitBody: func(request Request, body map[string]any) map[string]any {
|
||||||
|
return minimaxVideoPayload(request, body)
|
||||||
|
},
|
||||||
|
ResolveSuccess: minimaxResolveVideoFile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func minimaxVideoPayload(request Request, body map[string]any) map[string]any {
|
||||||
|
model := upstreamModelName(request.Candidate)
|
||||||
|
body["model"] = model
|
||||||
|
if strings.TrimSpace(stringFromAny(body["prompt"])) == "" {
|
||||||
|
body["prompt"] = mediaPromptText(body)
|
||||||
|
}
|
||||||
|
firstFrame, lastFrame := minimaxFrameImages(body)
|
||||||
|
if firstFrame != "" && strings.TrimSpace(stringFromAny(body["first_frame_image"])) == "" {
|
||||||
|
body["first_frame_image"] = firstFrame
|
||||||
|
}
|
||||||
|
if lastFrame != "" && strings.TrimSpace(stringFromAny(body["last_frame_image"])) == "" {
|
||||||
|
body["last_frame_image"] = lastFrame
|
||||||
|
}
|
||||||
|
if resolution := minimaxVideoResolution(model, stringFromAny(body["resolution"])); resolution != "" {
|
||||||
|
body["resolution"] = resolution
|
||||||
|
}
|
||||||
|
for _, key := range []string{
|
||||||
|
"content", "input", "_paramWarnings",
|
||||||
|
"first_frame", "firstFrame", "last_frame", "lastFrame",
|
||||||
|
"image", "images", "image_url", "imageUrl", "image_urls", "imageUrls",
|
||||||
|
"reference_image", "referenceImage", "duration_seconds", "durationSeconds",
|
||||||
|
} {
|
||||||
|
delete(body, key)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func minimaxFrameImages(body map[string]any) (string, string) {
|
||||||
|
firstFrame := firstNonEmptyStringValue(body, "first_frame_image", "firstFrameImage", "first_frame", "firstFrame")
|
||||||
|
lastFrame := firstNonEmptyStringValue(body, "last_frame_image", "lastFrameImage", "last_frame", "lastFrame")
|
||||||
|
imageURLs := firstNonEmptyStringListFromAny(body["image"], body["images"], body["image_url"], body["imageUrl"], body["image_urls"], body["imageUrls"])
|
||||||
|
if firstFrame == "" && len(imageURLs) > 0 {
|
||||||
|
firstFrame = imageURLs[0]
|
||||||
|
}
|
||||||
|
for _, item := range contentItems(body["content"]) {
|
||||||
|
if strings.TrimSpace(stringFromAny(item["type"])) != "image_url" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := minimaxNestedURL(item, "image_url")
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(stringFromAny(item["role"])) {
|
||||||
|
case "first_frame":
|
||||||
|
if firstFrame == "" {
|
||||||
|
firstFrame = url
|
||||||
|
}
|
||||||
|
case "last_frame":
|
||||||
|
if lastFrame == "" {
|
||||||
|
lastFrame = url
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if firstFrame == "" {
|
||||||
|
firstFrame = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstFrame, lastFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
func minimaxNestedURL(item map[string]any, key string) string {
|
||||||
|
if url := strings.TrimSpace(stringFromAny(item[key])); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
nested := mapFromAny(item[key])
|
||||||
|
return strings.TrimSpace(stringFromAny(nested["url"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func minimaxVideoResolution(model string, resolution string) string {
|
||||||
|
resolution = strings.TrimSpace(resolution)
|
||||||
|
if resolution == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
normalized := strings.ToLower(strings.ReplaceAll(resolution, " ", ""))
|
||||||
|
isHailuo23 := strings.Contains(strings.ToLower(model), "hailuo-2.3")
|
||||||
|
if isHailuo23 {
|
||||||
|
switch normalized {
|
||||||
|
case "720", "720p", "768", "768p":
|
||||||
|
return "768P"
|
||||||
|
case "1080", "1080p":
|
||||||
|
return "1080P"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch normalized {
|
||||||
|
case "512", "512p":
|
||||||
|
return "512P"
|
||||||
|
case "720", "720p":
|
||||||
|
return "720P"
|
||||||
|
case "768", "768p":
|
||||||
|
return "768P"
|
||||||
|
case "1080", "1080p":
|
||||||
|
return "1080P"
|
||||||
|
default:
|
||||||
|
if strings.HasSuffix(normalized, "p") {
|
||||||
|
return strings.TrimSuffix(normalized, "p") + "P"
|
||||||
|
}
|
||||||
|
return resolution
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func minimaxResolveVideoFile(ctx context.Context, client *http.Client, request Request, result map[string]any) (map[string]any, string, error) {
|
||||||
|
if len(providerTaskData(request, result)) > 0 {
|
||||||
|
return result, "", nil
|
||||||
|
}
|
||||||
|
fileID := strings.TrimSpace(stringFromPathValue(valueAtPath(result, "file_id")))
|
||||||
|
if fileID == "" {
|
||||||
|
return result, "", nil
|
||||||
|
}
|
||||||
|
fetched, requestID, err := providerGetJSON(ctx, client, providerURL(request.Candidate.BaseURL, "/files/retrieve?file_id="+url.QueryEscape(fileID)), request.Candidate.Credentials, "bearer")
|
||||||
|
if err != nil {
|
||||||
|
return nil, requestID, err
|
||||||
|
}
|
||||||
|
if isProviderTaskFailure(minimaxSpec(), fetched) {
|
||||||
|
return nil, requestID, providerTaskFailure(minimaxSpec(), fetched, firstNonEmptyString(requestID, requestIDFromResult(fetched)), time.Now())
|
||||||
|
}
|
||||||
|
downloadURL := firstNonEmptyString(
|
||||||
|
valueAtPath(fetched, "file.download_url"),
|
||||||
|
valueAtPath(fetched, "file.downloadUrl"),
|
||||||
|
valueAtPath(fetched, "download_url"),
|
||||||
|
valueAtPath(fetched, "downloadUrl"),
|
||||||
|
valueAtPath(fetched, "url"),
|
||||||
|
)
|
||||||
|
if downloadURL == "" {
|
||||||
|
return nil, requestID, &ClientError{Code: "invalid_response", Message: "minimax video download url is missing", RequestID: requestID, Retryable: false}
|
||||||
|
}
|
||||||
|
out := cloneMapAny(result)
|
||||||
|
out["video_url"] = downloadURL
|
||||||
|
if file, ok := fetched["file"].(map[string]any); ok {
|
||||||
|
out["file"] = cloneMapAny(file)
|
||||||
|
}
|
||||||
|
out["file_retrieve"] = cloneMapAny(fetched)
|
||||||
|
return out, firstNonEmptyString(requestID, requestIDFromResult(fetched)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c MinimaxClient) runSpeech(ctx context.Context, request Request) (Response, error) {
|
func (c MinimaxClient) runSpeech(ctx context.Context, request Request) (Response, error) {
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
payload := minimaxSpeechPayload(request)
|
payload := minimaxSpeechPayload(request)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type providerTaskSpec struct {
|
|||||||
FailureStatuses []string
|
FailureStatuses []string
|
||||||
ProcessStatuses []string
|
ProcessStatuses []string
|
||||||
DefaultSubmitBody func(Request, map[string]any) map[string]any
|
DefaultSubmitBody func(Request, map[string]any) map[string]any
|
||||||
|
ResolveSuccess func(context.Context, *http.Client, Request, map[string]any) (map[string]any, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type providerTaskClient struct {
|
type providerTaskClient struct {
|
||||||
@@ -54,6 +55,15 @@ func (c providerTaskClient) Run(ctx context.Context, request Request) (Response,
|
|||||||
return Response{}, providerTaskFailure(c.Spec, result, requestID, startedAt)
|
return Response{}, providerTaskFailure(c.Spec, result, requestID, startedAt)
|
||||||
}
|
}
|
||||||
if isProviderTaskSuccess(c.Spec, result) && hasProviderTaskResult(result) {
|
if isProviderTaskSuccess(c.Spec, result) && hasProviderTaskResult(result) {
|
||||||
|
resolved, resolvedRequestID, err := c.resolveSuccess(ctx, request, result)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, annotateResponseError(err, firstNonEmptyString(resolvedRequestID, requestID), startedAt, time.Now())
|
||||||
|
}
|
||||||
|
result = resolved
|
||||||
|
requestID = firstNonEmptyString(resolvedRequestID, requestID)
|
||||||
|
if isProviderTaskFailure(c.Spec, result) {
|
||||||
|
return Response{}, providerTaskFailure(c.Spec, result, requestID, startedAt)
|
||||||
|
}
|
||||||
return Response{
|
return Response{
|
||||||
Result: normalizeProviderTaskResult(request, c.Spec, result, ""),
|
Result: normalizeProviderTaskResult(request, c.Spec, result, ""),
|
||||||
RequestID: requestID,
|
RequestID: requestID,
|
||||||
@@ -96,6 +106,15 @@ func (c providerTaskClient) Run(ctx context.Context, request Request) (Response,
|
|||||||
lastResult = result
|
lastResult = result
|
||||||
requestID = firstNonEmptyString(pollRequestID, requestID, requestIDFromResult(result), upstreamTaskID)
|
requestID = firstNonEmptyString(pollRequestID, requestID, requestIDFromResult(result), upstreamTaskID)
|
||||||
if isProviderTaskSuccess(c.Spec, result) {
|
if isProviderTaskSuccess(c.Spec, result) {
|
||||||
|
resolved, resolvedRequestID, err := c.resolveSuccess(ctx, request, result)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, annotateResponseError(err, firstNonEmptyString(resolvedRequestID, requestID, upstreamTaskID), pollStarted, time.Now())
|
||||||
|
}
|
||||||
|
result = resolved
|
||||||
|
requestID = firstNonEmptyString(resolvedRequestID, requestID)
|
||||||
|
if isProviderTaskFailure(c.Spec, result) {
|
||||||
|
return Response{}, providerTaskFailure(c.Spec, result, requestID, startedAt)
|
||||||
|
}
|
||||||
finishedAt := time.Now()
|
finishedAt := time.Now()
|
||||||
return Response{
|
return Response{
|
||||||
Result: normalizeProviderTaskResult(request, c.Spec, result, upstreamTaskID),
|
Result: normalizeProviderTaskResult(request, c.Spec, result, upstreamTaskID),
|
||||||
@@ -119,6 +138,13 @@ func (c providerTaskClient) Run(ctx context.Context, request Request) (Response,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c providerTaskClient) resolveSuccess(ctx context.Context, request Request, result map[string]any) (map[string]any, string, error) {
|
||||||
|
if c.Spec.ResolveSuccess == nil {
|
||||||
|
return result, "", nil
|
||||||
|
}
|
||||||
|
return c.Spec.ResolveSuccess(ctx, httpClient(request.HTTPClient, c.HTTPClient), request, result)
|
||||||
|
}
|
||||||
|
|
||||||
func providerTaskKindSupported(kind string) bool {
|
func providerTaskKindSupported(kind string) bool {
|
||||||
switch kind {
|
switch kind {
|
||||||
case "images.generations", "images.edits", "videos.generations", "song.generations", "music.generations", "speech.generations":
|
case "images.generations", "images.edits", "videos.generations", "song.generations", "music.generations", "speech.generations":
|
||||||
@@ -282,9 +308,16 @@ func isProviderTaskSuccess(spec providerTaskSpec, result map[string]any) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isProviderTaskFailure(spec providerTaskSpec, result map[string]any) bool {
|
func isProviderTaskFailure(spec providerTaskSpec, result map[string]any) bool {
|
||||||
|
if statusCode := providerTaskBusinessStatusCode(result); statusCode != "" && statusCode != "0" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return containsStatus(append([]string{"failed", "failure", "error", "cancelled", "canceled", "fail", "expired", "task not found"}, spec.FailureStatuses...), providerTaskStatus(spec, result))
|
return containsStatus(append([]string{"failed", "failure", "error", "cancelled", "canceled", "fail", "expired", "task not found"}, spec.FailureStatuses...), providerTaskStatus(spec, result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func providerTaskBusinessStatusCode(result map[string]any) string {
|
||||||
|
return strings.TrimSpace(stringFromPathValue(valueAtPath(result, "base_resp.status_code")))
|
||||||
|
}
|
||||||
|
|
||||||
func containsStatus(values []string, status string) bool {
|
func containsStatus(values []string, status string) bool {
|
||||||
status = strings.ToLower(strings.TrimSpace(status))
|
status = strings.ToLower(strings.TrimSpace(status))
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
@@ -397,9 +430,9 @@ func appendURLValues(out *[]any, value any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func providerTaskFailure(spec providerTaskSpec, result map[string]any, requestID string, startedAt time.Time) error {
|
func providerTaskFailure(spec providerTaskSpec, result map[string]any, requestID string, startedAt time.Time) error {
|
||||||
message := firstNonEmptyString(valueAtPath(result, "message"), valueAtPath(result, "error.message"), valueAtPath(result, "error"), valueAtPath(result, "Response.ErrorMessage"), valueAtPath(result, "comment"), spec.Name+" task failed")
|
message := firstNonEmptyString(valueAtPath(result, "message"), valueAtPath(result, "error.message"), valueAtPath(result, "error"), valueAtPath(result, "base_resp.status_msg"), valueAtPath(result, "Response.ErrorMessage"), valueAtPath(result, "comment"), spec.Name+" task failed")
|
||||||
return &ClientError{
|
return &ClientError{
|
||||||
Code: firstNonEmptyString(valueAtPath(result, "code"), valueAtPath(result, "error_code"), valueAtPath(result, "Response.ErrorCode"), "provider_failed"),
|
Code: firstNonEmptyPathString(valueAtPath(result, "code"), valueAtPath(result, "error_code"), valueAtPath(result, "base_resp.status_code"), valueAtPath(result, "Response.ErrorCode"), "provider_failed"),
|
||||||
Message: message,
|
Message: message,
|
||||||
RequestID: requestID,
|
RequestID: requestID,
|
||||||
ResponseStartedAt: startedAt,
|
ResponseStartedAt: startedAt,
|
||||||
@@ -409,6 +442,15 @@ func providerTaskFailure(spec providerTaskSpec, result map[string]any, requestID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyPathString(values ...any) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if text := stringFromPathValue(value); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func providerPollInterval(request Request) time.Duration {
|
func providerPollInterval(request Request) time.Duration {
|
||||||
return durationFromConfig(request.Candidate.PlatformConfig, 2*time.Second, "pollIntervalMs", "poll_interval_ms")
|
return durationFromConfig(request.Candidate.PlatformConfig, 2*time.Second, "pollIntervalMs", "poll_interval_ms")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,75 @@ func TestProviderTaskClientsSubmitAndPoll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMinimaxClientNormalizesHailuo23PayloadAndRetrievesFile(t *testing.T) {
|
||||||
|
var submitted map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||||
|
t.Fatalf("unexpected auth header: %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("x-request-id", "req-minimax")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/video_generation":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&submitted); err != nil {
|
||||||
|
t.Fatalf("decode minimax submit request: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"task_id":"mm-task","base_resp":{"status_code":0,"status_msg":"success"}}`))
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/query/video_generation" && r.URL.Query().Get("task_id") == "mm-task":
|
||||||
|
_, _ = w.Write([]byte(`{"task_id":"mm-task","status":"Success","file_id":"file-1","base_resp":{"status_code":0,"status_msg":"success"}}`))
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/files/retrieve" && r.URL.Query().Get("file_id") == "file-1":
|
||||||
|
_, _ = w.Write([]byte(`{"file":{"download_url":"https://cdn.example/minimax-file.mp4"},"base_resp":{"status_code":0,"status_msg":"success"}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
response, err := (MinimaxClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
Model: "海螺2.3",
|
||||||
|
Body: map[string]any{
|
||||||
|
"resolution": "720p",
|
||||||
|
"duration": 6,
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "camera moves in"},
|
||||||
|
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/first.png"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
Provider: "minimax",
|
||||||
|
SpecType: "minimax",
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Credentials: map[string]any{"apiKey": "test-key"},
|
||||||
|
PlatformConfig: map[string]any{"pollIntervalMs": 1, "pollTimeoutMs": 1000},
|
||||||
|
ModelName: "海螺2.3",
|
||||||
|
ProviderModelName: "MiniMax-Hailuo-2.3",
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run minimax client: %v", err)
|
||||||
|
}
|
||||||
|
if submitted["model"] != "MiniMax-Hailuo-2.3" || submitted["prompt"] != "camera moves in" || submitted["first_frame_image"] != "https://example.com/first.png" {
|
||||||
|
t.Fatalf("unexpected minimax submit payload: %+v", submitted)
|
||||||
|
}
|
||||||
|
if submitted["resolution"] != "768P" {
|
||||||
|
t.Fatalf("hailuo 2.3 720p should be submitted as 768P, got %+v", submitted)
|
||||||
|
}
|
||||||
|
if _, ok := submitted["content"]; ok {
|
||||||
|
t.Fatalf("minimax native request should not include generic content: %+v", submitted)
|
||||||
|
}
|
||||||
|
data, _ := response.Result["data"].([]any)
|
||||||
|
if len(data) != 1 {
|
||||||
|
t.Fatalf("unexpected minimax response data: %+v", response.Result)
|
||||||
|
}
|
||||||
|
first, _ := data[0].(map[string]any)
|
||||||
|
if first["url"] != "https://cdn.example/minimax-file.mp4" {
|
||||||
|
t.Fatalf("unexpected minimax video url: %+v", response.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSunoClientSubmitsAndPollsAudioGeneration(t *testing.T) {
|
func TestSunoClientSubmitsAndPollsAudioGeneration(t *testing.T) {
|
||||||
var submitted map[string]any
|
var submitted map[string]any
|
||||||
var submittedRemoteTaskID string
|
var submittedRemoteTaskID string
|
||||||
@@ -303,6 +372,39 @@ func TestSunoClientSubmitsAndPollsAudioGeneration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderTaskClientFailureAndRetryableErrors(t *testing.T) {
|
func TestProviderTaskClientFailureAndRetryableErrors(t *testing.T) {
|
||||||
|
t.Run("submit business failure", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("x-request-id", "req-minimax-invalid")
|
||||||
|
if r.Method != http.MethodPost || r.URL.Path != "/video_generation" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"base_resp":{"status_code":1008,"status_msg":"invalid resolution"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
_, err := (MinimaxClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
ModelType: "video_generate",
|
||||||
|
Model: "海螺2.3",
|
||||||
|
Body: map[string]any{"model": "海螺2.3", "prompt": "hello"},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
Provider: "minimax",
|
||||||
|
SpecType: "minimax",
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Credentials: map[string]any{"apiKey": "test-key"},
|
||||||
|
PlatformConfig: map[string]any{"pollIntervalMs": 1, "pollTimeoutMs": 1000},
|
||||||
|
ModelName: "海螺2.3",
|
||||||
|
ProviderModelName: "MiniMax-Hailuo-2.3",
|
||||||
|
ModelType: "video_generate",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var clientErr *ClientError
|
||||||
|
if !errors.As(err, &clientErr) || clientErr.Code != "1008" || clientErr.Message != "invalid resolution" || clientErr.RequestID != "req-minimax-invalid" {
|
||||||
|
t.Fatalf("expected minimax business failure, got %#v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("poll failure", func(t *testing.T) {
|
t.Run("poll failure", func(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -28,6 +28,38 @@ func TestFilterRuntimeCandidatesByRequestResolutionKeepsSupportedCandidate(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterRuntimeCandidatesUsesStandardVideoResolutionValues(t *testing.T) {
|
||||||
|
candidates := []store.RuntimeModelCandidate{
|
||||||
|
{
|
||||||
|
PlatformID: "minimax",
|
||||||
|
PlatformKey: "minimax",
|
||||||
|
PlatformName: "MiniMax",
|
||||||
|
PlatformModelID: "hailuo23",
|
||||||
|
ModelName: "MiniMax-Hailuo-2.3",
|
||||||
|
ModelAlias: "海螺2.3",
|
||||||
|
ModelType: "video_generate",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"video_generate": map[string]any{
|
||||||
|
"output_resolutions": []any{"720p", "1080p"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered, summary, err := filterRuntimeCandidatesByRequest("videos.generations", "海螺2.3", "video_generate", map[string]any{
|
||||||
|
"resolution": "720p",
|
||||||
|
}, candidates)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("standard 720p request should match MiniMax catalog capability before client-side upstream mapping: %v", err)
|
||||||
|
}
|
||||||
|
if len(filtered) != 1 || filtered[0].PlatformModelID != "hailuo23" {
|
||||||
|
t.Fatalf("expected MiniMax candidate to remain, got %+v", filtered)
|
||||||
|
}
|
||||||
|
if summary["supportedCandidateCount"] != 1 || summary["filteredCandidateCount"] != 0 {
|
||||||
|
t.Fatalf("unexpected filter summary: %+v", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilterRuntimeCandidatesByScopedVideoResolution(t *testing.T) {
|
func TestFilterRuntimeCandidatesByScopedVideoResolution(t *testing.T) {
|
||||||
candidates := []store.RuntimeModelCandidate{
|
candidates := []store.RuntimeModelCandidate{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -323,6 +323,19 @@ func weightValueAliases(key string, name string) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch key {
|
switch key {
|
||||||
|
case "resolutionWeights":
|
||||||
|
aliases := []string{name}
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if lower != name {
|
||||||
|
aliases = append(aliases, lower)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(lower, "p") {
|
||||||
|
aliases = append(aliases, strings.TrimSuffix(lower, "p")+"P")
|
||||||
|
}
|
||||||
|
if lower == "768p" {
|
||||||
|
aliases = append(aliases, "720p")
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
case "audioWeights":
|
case "audioWeights":
|
||||||
return []string{name, "audio-" + name}
|
return []string{name, "audio-" + name}
|
||||||
case "referenceVideoWeights":
|
case "referenceVideoWeights":
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T)
|
|||||||
"video": map[string]any{
|
"video": map[string]any{
|
||||||
"basePrice": 100,
|
"basePrice": 100,
|
||||||
"dynamicWeight": map[string]any{
|
"dynamicWeight": map[string]any{
|
||||||
"resolutionWeights": map[string]any{"1080p": 1.5},
|
"resolutionWeights": map[string]any{"720p": 1.25, "1080p": 1.5},
|
||||||
"audioWeights": map[string]any{"true": 2},
|
"audioWeights": map[string]any{"true": 2},
|
||||||
"referenceVideoWeights": map[string]any{"true": 1.5},
|
"referenceVideoWeights": map[string]any{"true": 1.5},
|
||||||
"voiceSpecifiedWeights": map[string]any{"true": 1.2},
|
"voiceSpecifiedWeights": map[string]any{"true": 1.2},
|
||||||
@@ -59,7 +59,7 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T)
|
|||||||
items := service.billings(context.Background(), nil, "videos.generations", map[string]any{
|
items := service.billings(context.Background(), nil, "videos.generations", map[string]any{
|
||||||
"audio": true,
|
"audio": true,
|
||||||
"duration": 12,
|
"duration": 12,
|
||||||
"resolution": "1080p",
|
"resolution": "1080P",
|
||||||
"voice_id": "voice-a",
|
"voice_id": "voice-a",
|
||||||
"content": []any{
|
"content": []any{
|
||||||
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/reference.mp4"}},
|
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/reference.mp4"}},
|
||||||
@@ -82,6 +82,15 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T)
|
|||||||
if got, want := line["audioSource"], "preprocessed_request"; got != want {
|
if got, want := line["audioSource"], "preprocessed_request"; got != want {
|
||||||
t.Fatalf("video audio source = %v, want %v", got, want)
|
t.Fatalf("video audio source = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items = service.billings(context.Background(), nil, "videos.generations", map[string]any{
|
||||||
|
"duration": 5,
|
||||||
|
"resolution": "768P",
|
||||||
|
}, candidate, clients.Response{}, true)
|
||||||
|
line = firstBillingLine(t, items)
|
||||||
|
if got, want := floatFromAny(line["amount"]), 125.0; got != want {
|
||||||
|
t.Fatalf("768P should use 720p billing weight, got %v, want %v", got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMusicBillingUsesSongResourceAndOutputCount(t *testing.T) {
|
func TestMusicBillingUsesSongResourceAndOutputCount(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
WITH minimax_hailuo_defs(provider_model_name, capabilities) AS (
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'MiniMax-Hailuo-2.3',
|
||||||
|
'{
|
||||||
|
"video_generate": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"aspect_ratio_allowed": []
|
||||||
|
},
|
||||||
|
"image_to_video": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": false,
|
||||||
|
"input_last_frame": false,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
"aspect_ratio_allowed": [],
|
||||||
|
"support_video_effect_template": false
|
||||||
|
},
|
||||||
|
"originalTypes": ["video_generate", "image_to_video"]
|
||||||
|
}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'MiniMax-Hailuo-2.3-Fast',
|
||||||
|
'{
|
||||||
|
"image_to_video": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": false,
|
||||||
|
"input_last_frame": false,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
"aspect_ratio_allowed": [],
|
||||||
|
"support_video_effect_template": false
|
||||||
|
},
|
||||||
|
"originalTypes": ["image_to_video"]
|
||||||
|
}'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE base_model_catalog model
|
||||||
|
SET capabilities = defs.capabilities,
|
||||||
|
metadata = CASE
|
||||||
|
WHEN jsonb_typeof(model.metadata->'rawModel') = 'object' THEN jsonb_set(model.metadata, '{rawModel,capabilities}', defs.capabilities, true)
|
||||||
|
ELSE model.metadata
|
||||||
|
END,
|
||||||
|
default_snapshot = CASE
|
||||||
|
WHEN model.default_snapshot IS NULL THEN NULL
|
||||||
|
ELSE jsonb_set(
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(model.default_snapshot->'metadata'->'rawModel') = 'object' THEN jsonb_set(model.default_snapshot, '{metadata,rawModel,capabilities}', defs.capabilities, true)
|
||||||
|
ELSE model.default_snapshot
|
||||||
|
END,
|
||||||
|
'{capabilities}',
|
||||||
|
defs.capabilities,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
END,
|
||||||
|
updated_at = now()
|
||||||
|
FROM minimax_hailuo_defs defs
|
||||||
|
WHERE model.provider_key = 'minimax'
|
||||||
|
AND model.provider_model_name = defs.provider_model_name
|
||||||
|
AND COALESCE(model.metadata->>'source', '') = 'server-main.integration-platform';
|
||||||
|
|
||||||
|
WITH minimax_hailuo_defs(provider_model_name, capabilities) AS (
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'MiniMax-Hailuo-2.3',
|
||||||
|
'{
|
||||||
|
"video_generate": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"aspect_ratio_allowed": []
|
||||||
|
},
|
||||||
|
"image_to_video": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": false,
|
||||||
|
"input_last_frame": false,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
"aspect_ratio_allowed": [],
|
||||||
|
"support_video_effect_template": false
|
||||||
|
},
|
||||||
|
"originalTypes": ["video_generate", "image_to_video"]
|
||||||
|
}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'MiniMax-Hailuo-2.3-Fast',
|
||||||
|
'{
|
||||||
|
"image_to_video": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": false,
|
||||||
|
"input_last_frame": false,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
"aspect_ratio_allowed": [],
|
||||||
|
"support_video_effect_template": false
|
||||||
|
},
|
||||||
|
"originalTypes": ["image_to_video"]
|
||||||
|
}'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE platform_models model
|
||||||
|
SET capabilities = defs.capabilities,
|
||||||
|
updated_at = now()
|
||||||
|
FROM minimax_hailuo_defs defs, integration_platforms platform, base_model_catalog base_model
|
||||||
|
WHERE platform.provider = 'minimax'
|
||||||
|
AND platform.id = model.platform_id
|
||||||
|
AND base_model.id = model.base_model_id
|
||||||
|
AND COALESCE(model.provider_model_name, model.model_name) = defs.provider_model_name
|
||||||
|
AND COALESCE(base_model.metadata->>'source', '') = 'server-main.integration-platform';
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
WITH minimax_hailuo_defs(provider_model_name, capabilities) AS (
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'MiniMax-Hailuo-2.3',
|
||||||
|
'{
|
||||||
|
"video_generate": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"aspect_ratio_allowed": []
|
||||||
|
},
|
||||||
|
"image_to_video": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": false,
|
||||||
|
"input_last_frame": false,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
"aspect_ratio_allowed": [],
|
||||||
|
"support_video_effect_template": false
|
||||||
|
},
|
||||||
|
"originalTypes": ["video_generate", "image_to_video"]
|
||||||
|
}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'MiniMax-Hailuo-2.3-Fast',
|
||||||
|
'{
|
||||||
|
"image_to_video": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": false,
|
||||||
|
"input_last_frame": false,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
"aspect_ratio_allowed": [],
|
||||||
|
"support_video_effect_template": false
|
||||||
|
},
|
||||||
|
"originalTypes": ["image_to_video"]
|
||||||
|
}'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE base_model_catalog model
|
||||||
|
SET capabilities = defs.capabilities,
|
||||||
|
metadata = CASE
|
||||||
|
WHEN jsonb_typeof(model.metadata->'rawModel') = 'object' THEN jsonb_set(model.metadata, '{rawModel,capabilities}', defs.capabilities, true)
|
||||||
|
ELSE model.metadata
|
||||||
|
END,
|
||||||
|
default_snapshot = CASE
|
||||||
|
WHEN model.default_snapshot IS NULL THEN NULL
|
||||||
|
ELSE jsonb_set(
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(model.default_snapshot->'metadata'->'rawModel') = 'object' THEN jsonb_set(model.default_snapshot, '{metadata,rawModel,capabilities}', defs.capabilities, true)
|
||||||
|
ELSE model.default_snapshot
|
||||||
|
END,
|
||||||
|
'{capabilities}',
|
||||||
|
defs.capabilities,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
END,
|
||||||
|
updated_at = now()
|
||||||
|
FROM minimax_hailuo_defs defs
|
||||||
|
WHERE model.provider_key = 'minimax'
|
||||||
|
AND model.provider_model_name = defs.provider_model_name
|
||||||
|
AND COALESCE(model.metadata->>'source', '') = 'server-main.integration-platform';
|
||||||
|
|
||||||
|
WITH minimax_hailuo_defs(provider_model_name, capabilities) AS (
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'MiniMax-Hailuo-2.3',
|
||||||
|
'{
|
||||||
|
"video_generate": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"aspect_ratio_allowed": []
|
||||||
|
},
|
||||||
|
"image_to_video": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": false,
|
||||||
|
"input_last_frame": false,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
"aspect_ratio_allowed": [],
|
||||||
|
"support_video_effect_template": false
|
||||||
|
},
|
||||||
|
"originalTypes": ["video_generate", "image_to_video"]
|
||||||
|
}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'MiniMax-Hailuo-2.3-Fast',
|
||||||
|
'{
|
||||||
|
"image_to_video": {
|
||||||
|
"output_resolutions": ["720p", "1080p"],
|
||||||
|
"duration_range": {"720p": [6, 10], "1080p": [6, 6]},
|
||||||
|
"duration_options": {"720p": [6, 10], "1080p": [6]},
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": false,
|
||||||
|
"input_last_frame": false,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
"aspect_ratio_allowed": [],
|
||||||
|
"support_video_effect_template": false
|
||||||
|
},
|
||||||
|
"originalTypes": ["image_to_video"]
|
||||||
|
}'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE platform_models model
|
||||||
|
SET capabilities = defs.capabilities,
|
||||||
|
updated_at = now()
|
||||||
|
FROM minimax_hailuo_defs defs, integration_platforms platform, base_model_catalog base_model
|
||||||
|
WHERE platform.provider = 'minimax'
|
||||||
|
AND platform.id = model.platform_id
|
||||||
|
AND base_model.id = model.base_model_id
|
||||||
|
AND COALESCE(model.provider_model_name, model.model_name) = defs.provider_model_name
|
||||||
|
AND COALESCE(base_model.metadata->>'source', '') = 'server-main.integration-platform';
|
||||||
Reference in New Issue
Block a user