若干更新迭代
This commit is contained in:
1 parent
deb9cc5bcf
commit
4ac786aafb
64 files changed
+3251
-291
No files matched your search
@@ -3,6 +3,7 @@ package providers
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -64,10 +65,17 @@ func NewBailian(c Config, client HTTPClient) *Bailian {
|
||||
return "/api/v1/services/aigc/image-generation/generation"
|
||||
}, queryPath: func(id string) string { return "/api/v1/tasks/" + id }, payload: func(r Request) any {
|
||||
if r.Capability == "video.generate" {
|
||||
model := requestModel(r, c.Model)
|
||||
mode := stringValue(r.Settings["inputMode"])
|
||||
if mode == "" && model == "wan3.0-video" {
|
||||
mode = "reference"
|
||||
}
|
||||
media := make([]any, 0, len(r.InputURLs))
|
||||
for index, inputURL := range r.InputURLs {
|
||||
frameType := "first_frame"
|
||||
if index > 0 {
|
||||
if mode == "reference" {
|
||||
frameType = "reference_image"
|
||||
} else if index > 0 {
|
||||
frameType = "last_frame"
|
||||
}
|
||||
media = append(media, map[string]any{"type": frameType, "url": inputURL})
|
||||
@@ -84,7 +92,16 @@ func NewBailian(c Config, client HTTPClient) *Bailian {
|
||||
if parameters["duration"] == nil {
|
||||
parameters["duration"] = 10
|
||||
}
|
||||
return map[string]any{"model": requestModel(r, c.Model), "input": map[string]any{"prompt": r.Prompt, "media": media}, "parameters": parameters}
|
||||
if model == "wan3.0-video" {
|
||||
if ratio := stringValue(r.Settings["ratio"]); ratio != "" {
|
||||
parameters["ratio"] = ratio
|
||||
}
|
||||
}
|
||||
input := map[string]any{"prompt": r.Prompt}
|
||||
if len(media) > 0 {
|
||||
input["media"] = media
|
||||
}
|
||||
return map[string]any{"model": model, "input": input, "parameters": parameters}
|
||||
}
|
||||
content := make([]any, 0, len(r.InputURLs)+1)
|
||||
for _, inputURL := range r.InputURLs {
|
||||
@@ -126,7 +143,23 @@ func decodeBailian(raw []byte) Result {
|
||||
}
|
||||
}
|
||||
collectURLs(o["video_url"], &out)
|
||||
return Result{TaskID: stringValue(o["task_id"], r["task_id"]), Status: status(o["task_status"]), OutputURLs: out, ErrorMessage: stringValue(r["message"])}
|
||||
usage := map[string]int{}
|
||||
for _, value := range []any{object(r["usage"])["output_video_duration"], object(r["usage"])["duration"], object(o["usage"])["output_video_duration"], object(o["usage"])["duration"]} {
|
||||
if milliseconds, ok := positiveDurationMilliseconds(value); ok {
|
||||
usage["outputMilliseconds"] = milliseconds
|
||||
usage["outputSeconds"] = int(math.Ceil(float64(milliseconds) / 1000))
|
||||
break
|
||||
}
|
||||
}
|
||||
return Result{TaskID: stringValue(o["task_id"], r["task_id"]), Status: status(o["task_status"]), OutputURLs: out, ErrorMessage: stringValue(r["message"]), Usage: usage}
|
||||
}
|
||||
|
||||
func positiveDurationMilliseconds(value any) (int, bool) {
|
||||
seconds, ok := value.(float64)
|
||||
if !ok || math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds <= 0 || seconds > math.MaxInt32/1000 {
|
||||
return 0, false
|
||||
}
|
||||
return int(math.Ceil(seconds * 1000)), true
|
||||
}
|
||||
|
||||
type Seedance struct{ *httpAdapter }
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var providerRequestIDInMessage = regexp.MustCompile(`(?i)\brequest[ _-]?id\s*[:=]\s*([A-Za-z0-9_.:-]{3,128})\b`)
|
||||
|
||||
// SanitizeFailureDetail retains a short provider explanation while removing
|
||||
// submitted text, media links, credentials, and unrecognized quoted values.
|
||||
// It is safe to call again after the original request has been discarded.
|
||||
func SanitizeFailureDetail(message string, requestBody []byte, secrets ...string) string {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return ""
|
||||
}
|
||||
privateValues := append([]string(nil), secrets...)
|
||||
var request any
|
||||
if json.Unmarshal(requestBody, &request) == nil {
|
||||
collectPrivateRequestValues(request, "", &privateValues)
|
||||
}
|
||||
for _, value := range privateValues {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
message = strings.ReplaceAll(message, value, "[redacted]")
|
||||
encoded, _ := json.Marshal(value)
|
||||
if len(encoded) > 2 {
|
||||
message = strings.ReplaceAll(message, string(encoded[1:len(encoded)-1]), "[redacted]")
|
||||
}
|
||||
}
|
||||
message = minimaxDiagnosticURL.ReplaceAllString(message, "[redacted]")
|
||||
message = minimaxDiagnosticCredential.ReplaceAllString(message, "[redacted]")
|
||||
message = minimaxDiagnosticQuoted.ReplaceAllStringFunc(message, func(quoted string) string {
|
||||
value := quoted[1 : len(quoted)-1]
|
||||
if minimaxDiagnosticValue(value) != "unrecognized" {
|
||||
return quoted
|
||||
}
|
||||
return "[redacted]"
|
||||
})
|
||||
message = minimaxDiagnosticLongToken.ReplaceAllString(message, "[redacted]")
|
||||
message = strings.Join(strings.Fields(message), " ")
|
||||
runes := []rune(message)
|
||||
if len(runes) > 512 {
|
||||
return string(runes[:512]) + "...[truncated]"
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func collectPrivateRequestValues(value any, key string, values *[]string) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for childKey, childValue := range typed {
|
||||
collectPrivateRequestValues(childValue, strings.ToLower(childKey), values)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
collectPrivateRequestValues(child, key, values)
|
||||
}
|
||||
case string:
|
||||
if strings.Contains(key, "prompt") || strings.Contains(key, "text") || strings.Contains(key, "url") || strings.Contains(key, "image") || strings.Contains(key, "video") || strings.Contains(key, "audio") || strings.Contains(key, "token") || strings.Contains(key, "secret") || strings.Contains(key, "key") || strings.Contains(key, "signature") || strings.Contains(key, "password") || strings.Contains(key, "credential") {
|
||||
*values = append(*values, typed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDFromMessage(message string) string {
|
||||
match := providerRequestIDInMessage.FindStringSubmatch(message)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return safeHTTPProviderDiagnosticToken(match[1], 128)
|
||||
}
|
||||
|
||||
func safeProviderResponseDetail(raw, requestBody []byte, secrets ...string) string {
|
||||
root := record(raw)
|
||||
providerError := object(first(root["error"], root["Error"]))
|
||||
output := object(root["output"])
|
||||
data := object(root["data"])
|
||||
task := object(first(root["task"], data["task"]))
|
||||
nestedError := object(first(data["error"], task["error"], output["error"]))
|
||||
metadataError := object(first(object(first(root["ResponseMetadata"], root["response_metadata"]))["Error"], object(first(root["ResponseMetadata"], root["response_metadata"]))["error"]))
|
||||
message := stringValue(providerError["message"], providerError["Message"], nestedError["message"], nestedError["Message"], task["message"], output["message"], data["message"], metadataError["Message"], metadataError["message"], root["message"], root["Message"])
|
||||
return SanitizeFailureDetail(message, requestBody, secrets...)
|
||||
}
|
||||
|
||||
func successBusinessCode(code string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(code)) {
|
||||
case "", "0", "200", "ok", "success", "10000":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Only explicit top-level or output error indicators count as a business
|
||||
// rejection. A successful task can carry a normal informational message.
|
||||
func inspectProviderBusinessRejection(raw []byte, headers http.Header, requestBody []byte, secrets ...string) (code, requestID, detail string, rejected bool) {
|
||||
root := record(raw)
|
||||
providerError := object(first(root["error"], root["Error"]))
|
||||
output := object(root["output"])
|
||||
data := object(root["data"])
|
||||
task := object(first(root["task"], data["task"]))
|
||||
nestedError := object(first(data["error"], task["error"], output["error"]))
|
||||
code, requestID, _ = inspectHTTPProviderFailure(raw, headers)
|
||||
message := stringValue(providerError["message"], providerError["Message"], nestedError["message"], nestedError["Message"], output["message"], data["message"], root["message"])
|
||||
if (len(providerError) > 0 || len(nestedError) > 0) && (code != "" || message != "") {
|
||||
rejected = true
|
||||
} else if rootCode := safeHTTPProviderDiagnosticToken(first(root["code"], root["Code"]), 64); rootCode != "" && !successBusinessCode(rootCode) {
|
||||
rejected = true
|
||||
} else if outputCode := safeHTTPProviderDiagnosticToken(first(output["code"], output["Code"]), 64); outputCode != "" && !successBusinessCode(outputCode) {
|
||||
rejected = true
|
||||
} else if strings.EqualFold(stringValue(root["type"]), "error") && message != "" {
|
||||
rejected = true
|
||||
}
|
||||
if !rejected {
|
||||
return "", "", "", false
|
||||
}
|
||||
return code, requestID, SanitizeFailureDetail(message, requestBody, secrets...), true
|
||||
}
|
||||
|
||||
func annotateTerminalFailure(result Result, raw []byte, headers http.Header, requestBody []byte, secrets ...string) Result {
|
||||
if result.Status != StatusFailed && result.Status != StatusExpired && result.Status != StatusCancelled {
|
||||
return result
|
||||
}
|
||||
root := record(raw)
|
||||
business := object(first(root["Result"], root["result"]))
|
||||
data := object(root["data"])
|
||||
businessData := object(first(business["data"], business["Data"]))
|
||||
output := object(root["output"])
|
||||
task := object(root["task"])
|
||||
providerError := object(first(task["error"], data["error"], businessData["error"], businessData["Error"], business["error"], business["Error"], output["error"], root["error"], root["Error"]))
|
||||
code, requestID, _ := inspectHTTPProviderFailure(raw, headers)
|
||||
if nestedCode := firstSafeHTTPProviderDiagnosticToken(64, providerError["code"], providerError["Code"], task["error_code"], data["error_code"], businessData["error_code"], business["error_code"]); nestedCode != "" {
|
||||
code = nestedCode
|
||||
}
|
||||
if nestedID := firstSafeHTTPProviderDiagnosticToken(128, providerError["request_id"], providerError["requestId"], providerError["RequestId"], task["request_id"], data["request_id"], businessData["request_id"], business["request_id"]); nestedID != "" {
|
||||
requestID = nestedID
|
||||
}
|
||||
message := stringValue(providerError["message"], providerError["Message"], task["error_message"], task["message"], output["message"], data["error_message"], data["message"], businessData["error_message"], businessData["message"], business["message"], root["message"], result.ErrorMessage)
|
||||
if requestID == "" {
|
||||
requestID = requestIDFromMessage(message)
|
||||
}
|
||||
result.ErrorCode = code
|
||||
result.RequestID = requestID
|
||||
result.ErrorDetail = SanitizeFailureDetail(message, requestBody, secrets...)
|
||||
result.ErrorMessage = SanitizeFailureDetail(result.ErrorMessage, requestBody, secrets...)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdaptersPreserveExplicitProviderRejections(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, body, wantCode, wantDetail string
|
||||
newAdapter func(HTTPClient) Adapter
|
||||
}{
|
||||
{"seedance", `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"Input image may contain a real person. Request ID: req-safe-1"}}`, "InputImageSensitiveContentDetected.PrivacyInformation", "real person", func(c HTTPClient) Adapter {
|
||||
return NewSeedance(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c)
|
||||
}},
|
||||
{"seedream", `{"error":{"code":"InvalidImage","message":"Image format is invalid"}}`, "InvalidImage", "Image format", func(c HTTPClient) Adapter {
|
||||
return NewSeedream(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c)
|
||||
}},
|
||||
{"evolink", `{"data":{"error":{"code":"InvalidPrompt","message":"Prompt is invalid"}}}`, "InvalidPrompt", "Prompt is invalid", func(c HTTPClient) Adapter {
|
||||
return NewEvoLink(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c)
|
||||
}},
|
||||
{"bailian", `{"code":"InvalidParameter","message":"Invalid duration"}`, "InvalidParameter", "Invalid duration", func(c HTTPClient) Adapter {
|
||||
return NewBailian(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c)
|
||||
}},
|
||||
{"minimax", `{"type":"error","error":{"code":"1008","message":"Account balance is insufficient"}}`, "1008", "balance", func(c HTTPClient) Adapter {
|
||||
return NewMinimax(Config{BaseURL: "https://test.invalid", APIKey: "private-key"}, c)
|
||||
}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, httpStatus := range []int{http.StatusBadRequest, http.StatusOK} {
|
||||
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: httpStatus, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(tc.body))}, nil
|
||||
})
|
||||
result, err := tc.newAdapter(client).Submit(context.Background(), Request{Prompt: "private prompt"})
|
||||
if tc.name == "seedream" && httpStatus == http.StatusOK {
|
||||
if err != nil || result.Status != StatusFailed || result.ErrorCode != tc.wantCode || !strings.Contains(result.ErrorDetail, tc.wantDetail) {
|
||||
t.Fatalf("status=%d result=%#v err=%v", httpStatus, result, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var failure *ProviderError
|
||||
if !errors.As(err, &failure) || failure.Status != httpStatus || failure.Code != tc.wantCode || !strings.Contains(failure.Detail, tc.wantDetail) {
|
||||
t.Fatalf("status=%d failure=%#v err=%v", httpStatus, failure, err)
|
||||
}
|
||||
if tc.name == "seedance" && failure.RequestID != "req-safe-1" {
|
||||
t.Fatalf("request id=%q", failure.RequestID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulBusinessCodesAndInformationalMessagesDoNotReject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, response, taskID string
|
||||
wantURL bool
|
||||
adapter func(HTTPClient) Adapter
|
||||
}{
|
||||
{"evolink", `{"code":0,"message":"request accepted","data":{"id":"task-ev","status":"queued"}}`, "task-ev", false, func(c HTTPClient) Adapter { return NewEvoLink(Config{BaseURL: "https://test.invalid"}, c) }},
|
||||
{"bailian", `{"code":"200","message":"task submitted","output":{"task_id":"task-ba","task_status":"PENDING"}}`, "task-ba", false, func(c HTTPClient) Adapter { return NewBailian(Config{BaseURL: "https://test.invalid"}, c) }},
|
||||
{"seedance", `{"code":"success","message":"task completed","id":"task-se","status":"succeeded","content":{"video_url":"https://cdn.example/video.mp4"}}`, "task-se", true, func(c HTTPClient) Adapter { return NewSeedance(Config{BaseURL: "https://test.invalid"}, c) }},
|
||||
{"minimax", `{"code":0,"message":"request accepted","task_id":"task-mi"}`, "task-mi", false, func(c HTTPClient) Adapter { return NewMinimax(Config{BaseURL: "https://test.invalid"}, c) }},
|
||||
{"seedream", `{"code":0,"message":"generation complete","data":[{"url":"https://cdn.example/image.png"}]}`, "", true, func(c HTTPClient) Adapter { return NewSeedream(Config{BaseURL: "https://test.invalid"}, c) }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(tc.response))}, nil
|
||||
})
|
||||
result, err := tc.adapter(client).Submit(context.Background(), Request{Prompt: "safe prompt"})
|
||||
if err != nil || result.TaskID != tc.taskID || (tc.wantURL && len(result.OutputURLs) == 0) || (!tc.wantURL && len(result.OutputURLs) != 0) {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncTerminalFailurePersistsSafeDiagnostics(t *testing.T) {
|
||||
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"output":{"task_id":"task-1","task_status":"FAILED","code":"InvalidImage","message":"Input image was rejected"},"request_id":"req-bailian-1"}`))}, nil
|
||||
})
|
||||
result, err := NewBailian(Config{BaseURL: "https://test.invalid", APIKey: "secret"}, client).Query(context.Background(), "task-1")
|
||||
if err != nil || result.Status != StatusFailed || result.ErrorCode != "InvalidImage" || result.RequestID != "req-bailian-1" || result.ErrorDetail != "Input image was rejected" {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
encoded, err := EncodeResult(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persisted HTTPResult
|
||||
if err := json.Unmarshal(encoded, &persisted); err != nil || persisted.ErrorCode != result.ErrorCode || persisted.RequestID != result.RequestID || persisted.ErrorDetail != result.ErrorDetail {
|
||||
t.Fatalf("persisted=%#v err=%v", persisted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncAdaptersReadTerminalFailureDetails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, response, code, detail string
|
||||
adapter func(HTTPClient) Adapter
|
||||
}{
|
||||
{"evolink", `{"id":"task-1","status":"failed","data":{"error":{"code":"ContentRejected","message":"Reference image rejected"}}}`, "ContentRejected", "Reference image rejected", func(c HTTPClient) Adapter { return NewEvoLink(Config{BaseURL: "https://test.invalid"}, c) }},
|
||||
{"seedance", `{"id":"task-1","status":"failed","error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"Input image may contain a real person"}}`, "InputImageSensitiveContentDetected.PrivacyInformation", "real person", func(c HTTPClient) Adapter { return NewSeedance(Config{BaseURL: "https://test.invalid"}, c) }},
|
||||
{"minimax", `{"task":{"id":"task-1","status":"failed","error":{"code":"1026","message":"Input content rejected"}}}`, "1026", "Input content rejected", func(c HTTPClient) Adapter { return NewMinimax(Config{BaseURL: "https://test.invalid"}, c) }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(tc.response))}, nil
|
||||
})
|
||||
result, err := tc.adapter(client).Query(context.Background(), "task-1")
|
||||
if err != nil || result.Status != StatusFailed || result.ErrorCode != tc.code || !strings.Contains(result.ErrorDetail, tc.detail) {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFailureDetailRemovesSubmittedMaterialsCredentialsAndUnknownQuotes(t *testing.T) {
|
||||
request := []byte(`{"prompt":"private portrait","content":[{"text":"private portrait"},{"image_url":{"url":"https://private.example/photo.png"}}],"materials":[{"url":"https://private.example/video.mp4"}],"settings":{"token":"very-secret-token"}}`)
|
||||
message := `invalid "private portrait" from https://private.example/photo.png; video https://private.example/video.mp4; token=very-secret-token; Bearer other-secret; field "person's private name" rejected`
|
||||
got := SanitizeFailureDetail(message, request, "api-secret")
|
||||
for _, private := range []string{"private portrait", "private.example", "very-secret-token", "other-secret", "person's private name"} {
|
||||
if strings.Contains(got, private) {
|
||||
t.Fatalf("detail leaks %q: %q", private, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "invalid") || !strings.Contains(got, "rejected") {
|
||||
t.Fatalf("detail lost context: %q", got)
|
||||
}
|
||||
if got := SanitizeFailureDetail(strings.Repeat("x", 1000), nil); len([]rune(got)) > 527 {
|
||||
t.Fatalf("detail unbounded: %d", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubmitOutcomeKeepsStatusZero(t *testing.T) {
|
||||
for name, client := range map[string]HTTPClient{
|
||||
"transport": roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, io.ErrUnexpectedEOF }),
|
||||
"unreadable-response": roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 400, Header: http.Header{}, Body: &failingBody{}}, nil
|
||||
}),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := NewSeedance(Config{BaseURL: "https://test.invalid"}, client).Submit(context.Background(), Request{Prompt: "test"})
|
||||
var failure *ProviderError
|
||||
if !errors.As(err, &failure) || failure.Status != 0 {
|
||||
t.Fatalf("failure=%#v err=%v", failure, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcengineRejectionAndTerminalFailurePreserveSafeDiagnostics(t *testing.T) {
|
||||
responses := []string{
|
||||
`{"ResponseMetadata":{"RequestId":"req-volc-1","Error":{"Code":"50413","Message":"Input image rejected"}}}`,
|
||||
`{"code":10000,"Result":{"data":{"task_id":"task-volc","status":"failed","error":{"code":"InvalidImage","message":"Input image rejected"}}}}`,
|
||||
}
|
||||
call := 0
|
||||
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := responses[call]
|
||||
call++
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
})
|
||||
adapter := NewVolcengine(Config{BaseURL: "https://test.invalid", AccessKeyID: "private-ak", SecretAccessKey: "private-sk"}, client, func() time.Time { return time.Unix(0, 0) })
|
||||
_, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"})
|
||||
var failure *ProviderError
|
||||
if !errors.As(err, &failure) || failure.Status != 200 || failure.Code != "50413" || failure.RequestID != "req-volc-1" || failure.Detail != "Input image rejected" {
|
||||
t.Fatalf("failure=%#v err=%v", failure, err)
|
||||
}
|
||||
result, err := adapter.Query(context.Background(), "task-volc")
|
||||
if err != nil || result.Status != StatusFailed || result.ErrorCode != "InvalidImage" || result.ErrorDetail != "Input image rejected" {
|
||||
t.Fatalf("terminal result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingBody struct{}
|
||||
|
||||
func (*failingBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
||||
func (*failingBody) Close() error { return nil }
|
||||
@@ -0,0 +1,197 @@
|
||||
package providers
|
||||
|
||||
import "strings"
|
||||
|
||||
// FailureMessage returns a user-facing reason for a confirmed provider rejection
|
||||
// or a failed asynchronous task. The code must come from the named provider;
|
||||
// provider response prose must never be passed through to the user.
|
||||
//
|
||||
// Error-code references:
|
||||
// Volcengine Ark: https://docs.volcengine.com/docs/82379/1299023
|
||||
// Bailian: https://help.aliyun.com/zh/model-studio/error-code/
|
||||
// EvoLink API codes: https://evolink.ai/zh/blog/wan-2-7-api-guide
|
||||
// Jimeng visual codes: https://docs.volcengine.com/docs/AIGCAIzhongtaigongyongwendang/69728
|
||||
// and https://www.volcengine.com/docs/85621/1791184
|
||||
func FailureMessage(provider string, status int, code string) string {
|
||||
code = strings.TrimSpace(code)
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "seedance", "seedream":
|
||||
if message := arkFailureMessage(code); message != "" {
|
||||
return message
|
||||
}
|
||||
case "minimax":
|
||||
if message := minimaxFailureMessage(code); message != "" {
|
||||
return message
|
||||
}
|
||||
case "bailian":
|
||||
if message := bailianFailureMessage(code); message != "" {
|
||||
return message
|
||||
}
|
||||
case "evolink":
|
||||
if message := evolinkFailureMessage(code); message != "" {
|
||||
return message
|
||||
}
|
||||
case "volcengine-visual":
|
||||
if message := jimengFailureMessage(code); message != "" {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return failureMessageForStatus(status)
|
||||
}
|
||||
|
||||
func arkFailureMessage(code string) string {
|
||||
if code == "InputImageSensitiveContentDetected.PrivacyInformation" {
|
||||
return "参考图片可能包含真人,未通过服务商审核;请更换符合要求的图片后重试。"
|
||||
}
|
||||
if code == "InputVideoSensitiveContentDetected.PrivacyInformation" {
|
||||
return "参考视频可能包含真人,未通过服务商审核;请更换符合要求的视频后重试。"
|
||||
}
|
||||
for _, item := range []struct{ code, material string }{
|
||||
{"InputTextSensitiveContentDetected", "提示词"},
|
||||
{"InputImageSensitiveContentDetected", "参考图片"},
|
||||
{"InputVideoSensitiveContentDetected", "参考视频"},
|
||||
{"InputAudioSensitiveContentDetected", "参考音频"},
|
||||
{"OutputTextSensitiveContentDetected", "生成的文字"},
|
||||
{"OutputImageSensitiveContentDetected", "生成的图片"},
|
||||
{"OutputVideoSensitiveContentDetected", "生成的视频"},
|
||||
{"OutputAudioSensitiveContentDetected", "生成的音频"},
|
||||
} {
|
||||
if code == item.code+".PolicyViolation" {
|
||||
return item.material + "可能涉及版权限制,请调整内容后重试。"
|
||||
}
|
||||
if code == item.code || strings.HasPrefix(code, item.code+".") {
|
||||
return item.material + "未通过服务商内容审核,请调整内容后重试。"
|
||||
}
|
||||
}
|
||||
switch code {
|
||||
case "InvalidImageURL.EmptyURL", "InvalidImageURL.InvalidFormat", "InvalidParameter.TosURLInvalid":
|
||||
return "参考图片无法读取,请重新上传图片后重试。"
|
||||
case "OperationDenied.ServiceNotOpen", "OperationDenied.PermissionDenied":
|
||||
return "当前模型服务尚未开通或无权使用,请联系管理员处理。"
|
||||
case "OperationDenied.ServiceOverdue":
|
||||
return "生成服务账户已逾期,请联系管理员处理。"
|
||||
case "InvalidAccountStatus":
|
||||
return "生成服务账户状态异常,请联系管理员处理。"
|
||||
case "ContentSecurityDetectionError":
|
||||
return "服务商内容审核暂时不可用,请稍后重试。"
|
||||
}
|
||||
if code == "MissingParameter" || strings.HasPrefix(code, "MissingParameter.") || code == "InvalidParameter" || strings.HasPrefix(code, "InvalidParameter.") {
|
||||
return "当前生成参数不符合模型要求,请调整设置或切换模型后重试。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func minimaxFailureMessage(code string) string {
|
||||
switch code {
|
||||
case "1002":
|
||||
return "MiniMax 请求过于频繁,请稍后重试。"
|
||||
case "1004", "2049":
|
||||
return "MiniMax 服务凭证无效,请联系管理员处理。"
|
||||
case "1008":
|
||||
return "MiniMax 生成服务账户余额不足,请联系管理员处理。"
|
||||
case "1026":
|
||||
return "视频描述或参考素材未通过 MiniMax 内容审核,请调整后重试。"
|
||||
case "2013":
|
||||
return "当前生成参数不符合 MiniMax 模型要求,请调整设置后重试。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func bailianFailureMessage(code string) string {
|
||||
switch code {
|
||||
case "Arrearage", "isv.OUT_OF_SERVICE":
|
||||
return "百炼生成服务账户余额不足或逾期,请联系管理员处理。"
|
||||
case "DataInspectionFailed", "data_inspection_failed":
|
||||
return "输入或生成内容未通过百炼审核,请调整提示词或素材后重试。"
|
||||
case "IPInfringementSuspect":
|
||||
return "输入内容可能涉及知识产权限制,请调整提示词或素材后重试。"
|
||||
case "InvalidParameter", "InternalError.Algo.InvalidParameter":
|
||||
return "当前生成参数不符合百炼模型要求,请调整设置后重试。"
|
||||
case "InvalidURL", "InvalidURL.ConnectionRefused", "InvalidURL.Timeout", "InvalidFile.DownloadFailed", "InvalidParameter.FileDownload":
|
||||
return "参考素材无法下载,请重新上传可访问的素材后重试。"
|
||||
case "InvalidParameter.DataInspection":
|
||||
return "参考素材在安全审核时无法访问,请重新上传素材;若仍失败请联系管理员。"
|
||||
case "InvalidFile.Size", "InvalidFile.Format", "InvalidFile.Resolution", "InvalidFile.FPS", "InvalidFile.Duration", "InvalidFile.ImageSize", "InvalidFile.Openerror":
|
||||
return "参考素材的格式、大小或时长不符合模型要求,请更换素材后重试。"
|
||||
case "InvalidApiKey", "AccessDenied", "Resource.AccessDenied", "Workspace.AccessDenied":
|
||||
return "百炼生成服务鉴权或使用权限异常,请联系管理员处理。"
|
||||
case "Throttling":
|
||||
return "百炼请求过于频繁,请稍后重试。"
|
||||
case "InternalError", "InternalError.Algo":
|
||||
return "百炼生成服务暂时异常,请稍后重试。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func evolinkFailureMessage(code string) string {
|
||||
switch code {
|
||||
case "invalid_request":
|
||||
return "当前生成参数不符合模型要求,请检查提示词、素材和设置后重试。"
|
||||
case "unauthorized":
|
||||
return "EvoLink 生成服务鉴权失败,请联系管理员处理。"
|
||||
case "insufficient_quota":
|
||||
return "EvoLink 生成服务账户额度不足,请联系管理员处理。"
|
||||
case "model_access_denied":
|
||||
return "当前 EvoLink 模型无调用权限,请联系管理员处理。"
|
||||
case "rate_limit_exceeded":
|
||||
return "EvoLink 请求频率过高,请稍后重试。"
|
||||
case "internal_error":
|
||||
return "EvoLink 生成服务暂时异常,请稍后重试。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func jimengFailureMessage(code string) string {
|
||||
switch code {
|
||||
case "50200", "50201", "50204":
|
||||
return "当前生成参数不符合即梦模型要求,请调整设置后重试。"
|
||||
case "50205", "50206", "50207":
|
||||
return "参考图片过大、为空或无法读取,请更换图片后重试。"
|
||||
case "50209", "50210", "50211", "50214":
|
||||
return "参考视频的内容、大小或时长不符合要求,请更换视频后重试。"
|
||||
case "50213":
|
||||
return "提交的素材总大小超过即梦限制,请减少或压缩素材后重试。"
|
||||
case "60102":
|
||||
return "参考图片中未检测到人脸,请更换清晰的人像图片后重试。"
|
||||
case "60208", "50411":
|
||||
return "参考图片未通过即梦内容审核,请更换图片后重试。"
|
||||
case "50511":
|
||||
return "生成的图片未通过即梦内容审核,请调整提示词或素材后重试。"
|
||||
case "50412", "50413":
|
||||
return "提示词未通过即梦内容审核,请调整内容后重试。"
|
||||
case "50512":
|
||||
return "生成的文字未通过即梦内容审核,请调整提示词或素材后重试。"
|
||||
case "50400":
|
||||
return "即梦服务鉴权或调用权限异常,请联系管理员处理。"
|
||||
case "50402":
|
||||
return "即梦模型接口不可用,请联系管理员检查配置。"
|
||||
case "50429", "50430":
|
||||
return "即梦请求频率或并发达到上限,请稍后重试。"
|
||||
case "50500", "50501":
|
||||
return "即梦生成服务暂时异常,请稍后重试。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func failureMessageForStatus(status int) string {
|
||||
switch status {
|
||||
case 0:
|
||||
return "服务商未能完成生成,请联系管理员查看错误详情。"
|
||||
case 401:
|
||||
return "生成服务鉴权失败,请联系管理员处理。"
|
||||
case 402:
|
||||
return "生成服务账户额度不足,请联系管理员处理。"
|
||||
case 403:
|
||||
return "当前生成服务无权使用,请联系管理员处理。"
|
||||
case 404:
|
||||
return "模型或生成任务不存在,请联系管理员检查配置。"
|
||||
case 408, 504:
|
||||
return "服务商请求超时,无法确认是否已接收,请联系管理员核查任务状态。"
|
||||
case 429:
|
||||
return "生成服务请求频率过高,请稍后重试。"
|
||||
case 500, 502, 503:
|
||||
return "生成服务暂时异常,请稍后重试。"
|
||||
default:
|
||||
return "服务商拒绝了本次请求,请联系管理员查看错误详情。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFailureMessageUsesProviderSpecificCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, provider, code, want string
|
||||
status int
|
||||
}{
|
||||
{"seedance real-person image", "seedance", "InputImageSensitiveContentDetected.PrivacyInformation", "参考图片可能包含真人,未通过服务商审核;请更换符合要求的图片后重试。", 400},
|
||||
{"seedream real-person image", "seedream", "InputImageSensitiveContentDetected.PrivacyInformation", "参考图片可能包含真人,未通过服务商审核;请更换符合要求的图片后重试。", 400},
|
||||
{"seedance real-person video", "seedance", "InputVideoSensitiveContentDetected.PrivacyInformation", "参考视频可能包含真人,未通过服务商审核;请更换符合要求的视频后重试。", 400},
|
||||
{"seedance image copyright", "seedance", "InputImageSensitiveContentDetected.PolicyViolation", "参考图片可能涉及版权限制,请调整内容后重试。", 400},
|
||||
{"seedance image subcode", "seedance", "InputImageSensitiveContentDetected.OtherRestriction", "参考图片未通过服务商内容审核,请调整内容后重试。", 400},
|
||||
{"seedance parameter subcode", "seedance", "InvalidParameter.NewConstraint", "当前生成参数不符合模型要求,请调整设置或切换模型后重试。", 400},
|
||||
{"seedance missing parameter subcode", "seedance", "MissingParameter.ImageURL", "当前生成参数不符合模型要求,请调整设置或切换模型后重试。", 400},
|
||||
{"seedance audio moderation", "seedance", "InputAudioSensitiveContentDetected", "参考音频未通过服务商内容审核,请调整内容后重试。", 400},
|
||||
{"seedance output video moderation", "seedance", "OutputVideoSensitiveContentDetected", "生成的视频未通过服务商内容审核,请调整内容后重试。", 400},
|
||||
{"seedream old output image moderation", "seedream", "OutputImageSensitiveContentDetected", "生成的图片未通过服务商内容审核,请调整内容后重试。", 400},
|
||||
{"seedance account overdue", "seedance", "OperationDenied.ServiceOverdue", "生成服务账户已逾期,请联系管理员处理。", 403},
|
||||
{"seedance service unopened", "seedance", "OperationDenied.ServiceNotOpen", "当前模型服务尚未开通或无权使用,请联系管理员处理。", 403},
|
||||
{"minimax balance", "minimax", "1008", "MiniMax 生成服务账户余额不足,请联系管理员处理。", 400},
|
||||
{"minimax moderation", "minimax", "1026", "视频描述或参考素材未通过 MiniMax 内容审核,请调整后重试。", 400},
|
||||
{"minimax rate limit", "minimax", "1002", "MiniMax 请求过于频繁,请稍后重试。", 400},
|
||||
{"minimax invalid parameters", "minimax", "2013", "当前生成参数不符合 MiniMax 模型要求,请调整设置后重试。", 400},
|
||||
{"bailian arrearage", "bailian", "Arrearage", "百炼生成服务账户余额不足或逾期,请联系管理员处理。", 400},
|
||||
{"bailian copyright", "bailian", "IPInfringementSuspect", "输入内容可能涉及知识产权限制,请调整提示词或素材后重试。", 400},
|
||||
{"bailian inspection", "bailian", "DataInspectionFailed", "输入或生成内容未通过百炼审核,请调整提示词或素材后重试。", 400},
|
||||
{"bailian reference download", "bailian", "InvalidFile.DownloadFailed", "参考素材无法下载,请重新上传可访问的素材后重试。", 400},
|
||||
{"bailian inspection access", "bailian", "InvalidParameter.DataInspection", "参考素材在安全审核时无法访问,请重新上传素材;若仍失败请联系管理员。", 400},
|
||||
{"evolink balance by status", "evolink", "unrecognized", "生成服务账户额度不足,请联系管理员处理。", 402},
|
||||
{"evolink invalid request by code", "evolink", "invalid_request", "当前生成参数不符合模型要求,请检查提示词、素材和设置后重试。", 400},
|
||||
{"evolink quota by code", "evolink", "insufficient_quota", "EvoLink 生成服务账户额度不足,请联系管理员处理。", 400},
|
||||
{"evolink model access by code", "evolink", "model_access_denied", "当前 EvoLink 模型无调用权限,请联系管理员处理。", 400},
|
||||
{"jimeng text audit by business code", "volcengine-visual", "50413", "提示词未通过即梦内容审核,请调整内容后重试。", 200},
|
||||
{"jimeng image audit by business code", "volcengine-visual", "50411", "参考图片未通过即梦内容审核,请更换图片后重试。", 200},
|
||||
{"jimeng image too large by business code", "volcengine-visual", "50205", "参考图片过大、为空或无法读取,请更换图片后重试。", 200},
|
||||
{"jimeng concurrency by business code", "volcengine-visual", "50430", "即梦请求频率或并发达到上限,请稍后重试。", 200},
|
||||
{"jimeng forbidden by status", "volcengine-visual", "unrecognized", "当前生成服务无权使用,请联系管理员处理。", 403},
|
||||
{"unknown authorization", "evolink", "unrecognized", "生成服务鉴权失败,请联系管理员处理。", 401},
|
||||
{"unknown model", "evolink", "unrecognized", "模型或生成任务不存在,请联系管理员检查配置。", 404},
|
||||
{"unknown validation", "evolink", "unrecognized", "服务商拒绝了本次请求,请联系管理员查看错误详情。", 422},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := FailureMessage(tc.provider, tc.status, tc.code); got != tc.want {
|
||||
t.Errorf("FailureMessage(%q, %d, %q) = %q; want %q", tc.provider, tc.status, tc.code, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureMessageUnknownCodesStayGenericAndDoNotCrossProvider(t *testing.T) {
|
||||
cases := []struct {
|
||||
provider, code string
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{"seedance", "InputImageSensitiveContentDetectedEvil.PrivacyInformation", 400, "服务商拒绝了本次请求,请联系管理员查看错误详情。"},
|
||||
{"evolink", "InputImageSensitiveContentDetected.PrivacyInformation", 400, "服务商拒绝了本次请求,请联系管理员查看错误详情。"},
|
||||
{"minimax", "OperationDenied.ServiceOverdue", 400, "服务商拒绝了本次请求,请联系管理员查看错误详情。"},
|
||||
{"unknown", "1008", 400, "服务商拒绝了本次请求,请联系管理员查看错误详情。"},
|
||||
{"bailian", "unknown", 0, "服务商未能完成生成,请联系管理员查看错误详情。"},
|
||||
{"seedream", "unknown", 429, "生成服务请求频率过高,请稍后重试。"},
|
||||
{"seedream", "unknown", 504, "服务商请求超时,无法确认是否已接收,请联系管理员核查任务状态。"},
|
||||
{"seedream", "unknown", 500, "生成服务暂时异常,请稍后重试。"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := FailureMessage(tc.provider, tc.status, tc.code)
|
||||
if got != tc.want {
|
||||
t.Errorf("FailureMessage(%q, %d, %q) = %q; want %q", tc.provider, tc.status, tc.code, got, tc.want)
|
||||
}
|
||||
if strings.Contains(got, tc.code) && tc.code != "unknown" {
|
||||
t.Errorf("user message leaked provider code %q: %q", tc.code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
const MinimaxH3Model = "MiniMax-H3"
|
||||
|
||||
// Minimax adapts the MiniMax H3 V2 asynchronous video API to the common
|
||||
// provider contract. The first release intentionally supports text-to-video
|
||||
// and a single first-frame image only; request validation lives in jobs.
|
||||
// provider contract. Request validation for first-frame and multi-reference
|
||||
// modes lives in jobs.
|
||||
type Minimax struct{ *httpAdapter }
|
||||
|
||||
func NewMinimax(c Config, client HTTPClient) *Minimax {
|
||||
@@ -30,13 +30,20 @@ func NewMinimax(c Config, client HTTPClient) *Minimax {
|
||||
content := []any{map[string]any{"type": "text", "text": r.Prompt}}
|
||||
materials := r.Materials
|
||||
if len(materials) == 0 && len(r.InputURLs) > 0 {
|
||||
materials = []Material{{URL: r.InputURLs[0], Type: MaterialImage}}
|
||||
materials = make([]Material, 0, len(r.InputURLs))
|
||||
for _, inputURL := range r.InputURLs {
|
||||
materials = append(materials, Material{URL: inputURL, Type: MaterialImage})
|
||||
}
|
||||
}
|
||||
if len(materials) > 0 {
|
||||
role := "first_frame"
|
||||
if r.Settings["inputMode"] == "reference" {
|
||||
role = "reference_image"
|
||||
}
|
||||
for _, material := range materials {
|
||||
content = append(content, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{"url": materials[0].URL},
|
||||
"role": "first_frame",
|
||||
"image_url": map[string]any{"url": material.URL},
|
||||
"role": role,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
|
||||
@@ -66,6 +66,9 @@ type Result struct {
|
||||
OutputURLs []string
|
||||
Raw json.RawMessage
|
||||
ErrorMessage string
|
||||
ErrorCode string
|
||||
RequestID string
|
||||
ErrorDetail string
|
||||
Usage map[string]int
|
||||
}
|
||||
|
||||
@@ -78,6 +81,9 @@ type HTTPResult struct {
|
||||
OutputURLs []string `json:"outputUrls"`
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
ErrorDetail string `json:"errorDetail,omitempty"`
|
||||
Usage map[string]int `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
@@ -86,7 +92,7 @@ func EncodeResult(result Result) (json.RawMessage, error) {
|
||||
if urls == nil {
|
||||
urls = []string{}
|
||||
}
|
||||
return json.Marshal(HTTPResult{TaskID: result.TaskID, Status: result.Status, OutputURLs: urls, Raw: result.Raw, ErrorMessage: result.ErrorMessage, Usage: result.Usage})
|
||||
return json.Marshal(HTTPResult{TaskID: result.TaskID, Status: result.Status, OutputURLs: urls, Raw: result.Raw, ErrorMessage: result.ErrorMessage, ErrorCode: result.ErrorCode, RequestID: result.RequestID, ErrorDetail: SanitizeFailureDetail(result.ErrorDetail, nil), Usage: result.Usage})
|
||||
}
|
||||
|
||||
type Adapter interface {
|
||||
@@ -211,10 +217,15 @@ func (a *httpAdapter) call(ctx context.Context, method, path string, body []byte
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
code, requestID, errorType := inspectHTTPProviderFailure(raw, resp.Header)
|
||||
var detail string
|
||||
// Only a submission carries the original content needed for redaction.
|
||||
detail := safeProviderResponseDetail(raw, body, a.config.APIKey)
|
||||
if operation == "submit" && a.failureDetail != nil {
|
||||
detail = a.failureDetail(raw, body)
|
||||
if adapterDetail := a.failureDetail(raw, body); adapterDetail != "" {
|
||||
detail = SanitizeFailureDetail(adapterDetail, body, a.config.APIKey)
|
||||
}
|
||||
}
|
||||
loggedDetail := ""
|
||||
if a.name == "minimax" {
|
||||
loggedDetail = detail
|
||||
}
|
||||
logHTTPProviderFailure(httpProviderDiagnostic{
|
||||
Provider: a.name,
|
||||
@@ -225,7 +236,7 @@ func (a *httpAdapter) call(ctx context.Context, method, path string, body []byte
|
||||
ErrorType: errorType,
|
||||
ErrorClass: "service",
|
||||
ElapsedMS: elapsedMilliseconds(startedAt),
|
||||
Detail: detail,
|
||||
Detail: loggedDetail,
|
||||
})
|
||||
return Result{}, &ProviderError{Operation: a.name + " " + operation, Status: resp.StatusCode, Code: code, RequestID: requestID, Detail: detail}
|
||||
}
|
||||
@@ -236,6 +247,13 @@ func (a *httpAdapter) call(ctx context.Context, method, path string, body []byte
|
||||
return Result{}, &ProviderError{Operation: a.name + " " + operation}
|
||||
}
|
||||
result := a.decode(raw)
|
||||
if result.Status != StatusFailed && result.Status != StatusExpired && result.Status != StatusCancelled {
|
||||
if code, requestID, detail, rejected := inspectProviderBusinessRejection(raw, resp.Header, body, a.config.APIKey); rejected {
|
||||
logHTTPProviderFailure(httpProviderDiagnostic{Provider: a.name, Operation: operation, Status: resp.StatusCode, Code: code, RequestID: requestID, ErrorClass: "service", ElapsedMS: elapsedMilliseconds(startedAt)})
|
||||
return Result{}, &ProviderError{Operation: a.name + " " + operation, Status: resp.StatusCode, Code: code, RequestID: requestID, Detail: detail}
|
||||
}
|
||||
}
|
||||
result = annotateTerminalFailure(result, raw, resp.Header, body, a.config.APIKey)
|
||||
result.Raw = append(json.RawMessage(nil), raw...)
|
||||
return result, nil
|
||||
}
|
||||
@@ -283,21 +301,35 @@ func inspectHTTPProviderFailure(raw []byte, headers http.Header) (code, requestI
|
||||
}
|
||||
metadata := object(first(root["ResponseMetadata"], root["response_metadata"]))
|
||||
metadataError := object(first(metadata["Error"], metadata["error"]))
|
||||
output := object(root["output"])
|
||||
data := object(root["data"])
|
||||
task := object(first(root["task"], data["task"]))
|
||||
nestedError := object(first(data["error"], task["error"], output["error"]))
|
||||
|
||||
code = safeHTTPProviderDiagnosticToken(first(
|
||||
code = firstSafeHTTPProviderDiagnosticToken(64,
|
||||
providerError["code"], providerError["Code"],
|
||||
nestedError["code"], nestedError["Code"],
|
||||
metadataError["code"], metadataError["Code"],
|
||||
output["code"], output["Code"],
|
||||
data["code"], data["Code"],
|
||||
root["code"], root["Code"],
|
||||
), 64)
|
||||
)
|
||||
if code == "" {
|
||||
code = parenthesizedNumericCode(stringValue(providerError["message"], root["message"]))
|
||||
code = parenthesizedNumericCode(stringValue(providerError["message"], nestedError["message"], output["message"], data["message"], root["message"]))
|
||||
}
|
||||
requestID = safeHTTPProviderDiagnosticToken(first(
|
||||
requestID = firstSafeHTTPProviderDiagnosticToken(128,
|
||||
providerError["request_id"], providerError["requestId"], providerError["RequestId"], providerError["RequestID"],
|
||||
nestedError["request_id"], nestedError["requestId"], nestedError["RequestId"], nestedError["RequestID"],
|
||||
root["request_id"], root["requestId"], root["RequestId"], root["RequestID"],
|
||||
data["request_id"], data["requestId"], data["RequestId"], data["RequestID"],
|
||||
task["request_id"], task["requestId"], task["RequestId"], task["RequestID"],
|
||||
metadata["request_id"], metadata["requestId"], metadata["RequestId"], metadata["RequestID"],
|
||||
output["request_id"], output["requestId"], output["RequestId"], output["RequestID"],
|
||||
headers.Get("X-Tt-Logid"), headers.Get("X-Request-Id"),
|
||||
), 128)
|
||||
)
|
||||
if requestID == "" {
|
||||
requestID = requestIDFromMessage(stringValue(providerError["message"], nestedError["message"], output["message"], data["message"], root["message"]))
|
||||
}
|
||||
errorType = safeHTTPProviderDiagnosticToken(first(
|
||||
providerError["type"], providerError["Type"],
|
||||
metadataError["type"], metadataError["Type"],
|
||||
@@ -354,6 +386,15 @@ func safeHTTPProviderDiagnosticToken(value any, maxLength int) string {
|
||||
return token
|
||||
}
|
||||
|
||||
func firstSafeHTTPProviderDiagnosticToken(maxLength int, values ...any) string {
|
||||
for _, value := range values {
|
||||
if token := safeHTTPProviderDiagnosticToken(value, maxLength); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func classifyHTTPProviderTransportError(err error) string {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "canceled"
|
||||
|
||||
@@ -738,6 +738,33 @@ func TestBailianDecodesCompatibleModeChoiceImages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBailianWan30ReferencePayloadAndActualDuration(t *testing.T) {
|
||||
adapter := NewBailian(Config{Model: "wan2.7-i2v-2026-04-25"}, nil)
|
||||
request := Request{Capability: "video.generate", Model: "wan3.0-video", Prompt: "combine", InputURLs: []string{"https://cdn.test/a.png", "https://cdn.test/b.png"}, Settings: map[string]any{"resolution": "480P", "duration": 20, "ratio": "9:16"}}
|
||||
payload := adapter.payload(request).(map[string]any)
|
||||
media := payload["input"].(map[string]any)["media"].([]any)
|
||||
if payload["model"] != "wan3.0-video" || len(media) != 2 || media[0].(map[string]any)["type"] != "reference_image" || media[1].(map[string]any)["type"] != "reference_image" {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
if payload["parameters"].(map[string]any)["ratio"] != "9:16" {
|
||||
t.Fatalf("parameters=%#v", payload["parameters"])
|
||||
}
|
||||
request.Settings["inputMode"] = "frames"
|
||||
media = adapter.payload(request).(map[string]any)["input"].(map[string]any)["media"].([]any)
|
||||
if media[0].(map[string]any)["type"] != "first_frame" || media[1].(map[string]any)["type"] != "last_frame" {
|
||||
t.Fatalf("media=%#v", media)
|
||||
}
|
||||
request.InputURLs = nil
|
||||
request.Settings["inputMode"] = "reference"
|
||||
if _, exists := adapter.payload(request).(map[string]any)["input"].(map[string]any)["media"]; exists {
|
||||
t.Fatal("Wan 3.0 text-only request should omit media")
|
||||
}
|
||||
result := decodeBailian([]byte(`{"output":{"task_id":"task-1","task_status":"SUCCEEDED","video_url":"https://cdn.test/v.mp4"},"usage":{"output_video_duration":5.25,"duration":8}}`))
|
||||
if result.Usage["outputMilliseconds"] != 5250 || result.Usage["outputSeconds"] != 6 {
|
||||
t.Fatalf("usage=%#v", result.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedance20PayloadMatchesOfficialMultimodalContract(t *testing.T) {
|
||||
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
var body map[string]any
|
||||
|
||||
@@ -146,11 +146,11 @@ func (v *Volcengine) call(ctx context.Context, action, version string, payload a
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_read", ElapsedMS: elapsedMilliseconds(startedAt)})
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Cause: err}
|
||||
}
|
||||
if int64(len(raw)) > limit {
|
||||
logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_too_large", ElapsedMS: elapsedMilliseconds(startedAt)})
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
|
||||
return Result{}, &ProviderError{Operation: "volcengine request"}
|
||||
}
|
||||
validJSON := json.Valid(raw)
|
||||
r := map[string]any{}
|
||||
@@ -169,7 +169,7 @@ func (v *Volcengine) call(ctx context.Context, action, version string, payload a
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
diagnostic.ErrorClass = "service"
|
||||
logVolcengineFailure(diagnostic)
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode, Code: diagnostic.Code}
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode, Code: diagnostic.Code, RequestID: diagnostic.RequestID, Detail: safeProviderResponseDetail(raw, body, v.config.AccessKeyID, v.config.SecretAccessKey)}
|
||||
}
|
||||
if !validJSON {
|
||||
diagnostic.ErrorClass = "invalid_json"
|
||||
@@ -179,14 +179,19 @@ func (v *Volcengine) call(ctx context.Context, action, version string, payload a
|
||||
if response.code != nil && !volcengineRequestSucceeded(response.code) {
|
||||
diagnostic.ErrorClass = "service"
|
||||
logVolcengineFailure(diagnostic)
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Code: diagnostic.Code}
|
||||
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode, Code: diagnostic.Code, RequestID: diagnostic.RequestID, Detail: safeProviderResponseDetail(raw, body, v.config.AccessKeyID, v.config.SecretAccessKey)}
|
||||
}
|
||||
d := object(first(response.business["data"], response.business["Data"]))
|
||||
out := []string{}
|
||||
for _, value := range []any{d["image_urls"], d["image_url"], d["url"], d["result_url"], d["output"], d["outputs"]} {
|
||||
collectURLs(value, &out)
|
||||
}
|
||||
return Result{TaskID: stringValue(response.business["task_id"], response.business["TaskId"], d["task_id"], d["TaskId"]), Status: status(first(d["status"], d["Status"], response.business["status"], response.business["Status"])), OutputURLs: out, Raw: raw}, nil
|
||||
result := Result{TaskID: stringValue(response.business["task_id"], response.business["TaskId"], d["task_id"], d["TaskId"]), Status: status(first(d["status"], d["Status"], response.business["status"], response.business["Status"])), OutputURLs: out, Raw: raw}
|
||||
result = annotateTerminalFailure(result, raw, resp.Header, body, v.config.AccessKeyID, v.config.SecretAccessKey)
|
||||
if result.Status == StatusFailed && result.ErrorDetail == "" {
|
||||
result.ErrorDetail = SanitizeFailureDetail(stringValue(d["message"], response.business["message"]), body, v.config.AccessKeyID, v.config.SecretAccessKey)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type volcengineResponse struct {
|
||||
@@ -215,11 +220,12 @@ func inspectVolcengineResponse(root map[string]any, headers http.Header) volceng
|
||||
business["code_n"], business["codeN"], business["CodeN"],
|
||||
root["code_n"], root["codeN"], root["CodeN"],
|
||||
),
|
||||
requestID: first(
|
||||
requestID: firstSafeHTTPProviderDiagnosticToken(128,
|
||||
business["request_id"], business["requestId"], business["RequestId"], business["RequestID"],
|
||||
root["request_id"], root["requestId"], root["RequestId"], root["RequestID"],
|
||||
metadata["RequestId"], metadata["RequestID"], metadata["request_id"], metadata["requestId"],
|
||||
headers.Get("X-Tt-Logid"), headers.Get("X-Request-Id"),
|
||||
requestIDFromMessage(stringValue(gatewayError["Message"], gatewayError["message"], business["message"], root["message"])),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user