186 lines
11 KiB
Go
186 lines
11 KiB
Go
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 }
|