功能增加
This commit is contained in:
1 parent
b340fc7ad7
commit
f1993eb388
69 files changed
+2243
-162
No files matched your search
@@ -0,0 +1,51 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestA4ImageAdapterDimensions(t *testing.T) {
|
||||
for _, size := range []struct {
|
||||
width, height int
|
||||
evoSize, bailianSize string
|
||||
}{
|
||||
{1680, 2376, "848x1200", "1680*2376"},
|
||||
{2376, 1680, "1200x848", "2376*1680"},
|
||||
{848, 1200, "848x1200", "848*1200"},
|
||||
{1200, 848, "1200x848", "1200*848"},
|
||||
} {
|
||||
request := Request{Capability: "image.generate", Prompt: "A4 itinerary", Settings: map[string]any{"width": size.width, "height": size.height}}
|
||||
evo := NewEvoLink(Config{}, nil).payload(request).(map[string]any)
|
||||
if evo["size"] != size.evoSize || evo["resolution"] != "1K" {
|
||||
t.Fatalf("evolink=%#v", evo)
|
||||
}
|
||||
bailian := NewBailian(Config{}, nil).payload(request).(map[string]any)
|
||||
if object(bailian["parameters"])["size"] != size.bailianSize {
|
||||
t.Fatalf("bailian=%#v", bailian)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestA4JimengDimensionsAreSubmittedUnchanged(t *testing.T) {
|
||||
for _, size := range [][2]int{{1680, 2376}, {2376, 1680}} {
|
||||
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["width"] != float64(size[0]) || body["height"] != float64(size[1]) {
|
||||
t.Fatalf("body=%#v", body)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"code":10000,"data":{"task_id":"test-a4"}}`))}, nil
|
||||
})
|
||||
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", Model: "jimeng_seedream46_cvtob", AccessKeyID: "test-key", SecretAccessKey: "test-secret"}, client, nil)
|
||||
if _, err := adapter.Submit(context.Background(), Request{Capability: "image.generate", Prompt: "A4 itinerary", Settings: map[string]any{"width": size[0], "height": size[1]}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,13 @@ func NewEvoLink(c Config, client HTTPClient) *EvoLink {
|
||||
payload["size"] = size
|
||||
} else if width, widthOK := positiveInteger(r.Settings["width"]); widthOK {
|
||||
if height, heightOK := positiveInteger(r.Settings["height"]); heightOK {
|
||||
// A4 custom pixels ignore the resolution shortcut. Match the
|
||||
// frontend's 1K budget rather than silently requesting a 2K image.
|
||||
if width == 1680 && height == 2376 {
|
||||
width, height = 848, 1200
|
||||
} else if width == 2376 && height == 1680 {
|
||||
width, height = 1200, 848
|
||||
}
|
||||
payload["size"] = supportedRatio(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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.
|
||||
type Minimax struct{ *httpAdapter }
|
||||
|
||||
func NewMinimax(c Config, client HTTPClient) *Minimax {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if strings.TrimSpace(c.Model) == "" {
|
||||
c.Model = MinimaxH3Model
|
||||
}
|
||||
a := &httpAdapter{
|
||||
name: "minimax",
|
||||
config: c,
|
||||
client: client,
|
||||
submitPath: func(Request) string { return "/v2/video_generation" },
|
||||
queryPath: func(id string) string { return "/v2/query/video_generation/" + id },
|
||||
payload: func(r Request) any {
|
||||
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}}
|
||||
}
|
||||
if len(materials) > 0 {
|
||||
content = append(content, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{"url": materials[0].URL},
|
||||
"role": "first_frame",
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"model": requestModel(r, c.Model),
|
||||
"content": content,
|
||||
"resolution": r.Settings["resolution"],
|
||||
"duration": r.Settings["duration"],
|
||||
"ratio": r.Settings["ratio"],
|
||||
"aigc_watermark": false,
|
||||
}
|
||||
},
|
||||
decode: decodeMinimax,
|
||||
failureDetail: func(responseBody, requestBody []byte) string {
|
||||
return minimaxSafeFailureDetail(responseBody, requestBody, c.APIKey)
|
||||
},
|
||||
}
|
||||
return &Minimax{a}
|
||||
}
|
||||
|
||||
func (a *Minimax) Submit(ctx context.Context, request Request) (Result, error) {
|
||||
result, err := a.submit(ctx, request)
|
||||
if err != nil {
|
||||
logMinimaxFailedRequest(request, a.config, err)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (a *Minimax) Query(ctx context.Context, id string) (Result, error) {
|
||||
return a.query(ctx, id)
|
||||
}
|
||||
|
||||
func decodeMinimax(raw []byte) Result {
|
||||
root := record(raw)
|
||||
task := object(root["task"])
|
||||
if len(task) == 0 {
|
||||
return Result{TaskID: stringValue(root["task_id"]), Status: StatusQueued}
|
||||
}
|
||||
outputURLs := []string{}
|
||||
collectURLs(object(task["content"])["url"], &outputURLs)
|
||||
usage := map[string]int{}
|
||||
for source, target := range map[string]string{
|
||||
"total_seconds": "totalSeconds",
|
||||
"input_seconds": "inputSeconds",
|
||||
"output_seconds": "outputSeconds",
|
||||
"input_image_count": "inputImageCount",
|
||||
"input_audio_seconds": "inputAudioSeconds",
|
||||
"total_tokens": "totalTokens",
|
||||
"prompt_tokens": "promptTokens",
|
||||
"completion_tokens": "completionTokens",
|
||||
} {
|
||||
if number, ok := object(task["usage"])[source].(float64); ok && number >= 0 {
|
||||
usage[target] = int(number)
|
||||
}
|
||||
}
|
||||
providerError := object(task["error"])
|
||||
resultStatus := status(task["status"])
|
||||
errorMessage := minimaxTaskFailureMessage(safeHTTPProviderDiagnosticToken(providerError["code"], 16), stringValue(providerError["message"]))
|
||||
if resultStatus == StatusFailed && errorMessage == "" {
|
||||
errorMessage = "MiniMax 视频生成失败,请调整提示词或素材后重试。"
|
||||
}
|
||||
return Result{
|
||||
TaskID: stringValue(task["id"], root["task_id"]),
|
||||
Status: resultStatus,
|
||||
OutputURLs: outputURLs,
|
||||
ErrorMessage: errorMessage,
|
||||
Usage: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func minimaxTaskFailureMessage(code, message string) string {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "1008":
|
||||
return "MiniMax API 账户余额不足,请充值后重试。"
|
||||
case "1026":
|
||||
return "视频描述或输入素材触发内容安全审核,请调整后重试。"
|
||||
case "1002":
|
||||
return "MiniMax 请求频率过高,请稍后重试。"
|
||||
}
|
||||
if strings.TrimSpace(message) != "" {
|
||||
return "MiniMax 视频生成失败,请调整提示词或素材后重试。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var _ Adapter = (*Minimax)(nil)
|
||||
@@ -0,0 +1,114 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
minimaxDiagnosticURL = regexp.MustCompile(`(?i)(?:https?://|data:)[^\s"'<>]+`)
|
||||
minimaxDiagnosticCredential = regexp.MustCompile(`(?i)(?:bearer\s+|(?:authorization|api[_-]?key|access[_-]?key|secret|token|password|signature|credential)\s*["']?\s*[:=]\s*["']?(?:bearer\s+)?)[^\s,"';}]+`)
|
||||
minimaxDiagnosticQuoted = regexp.MustCompile("\"(?:[^\"\\\\]|\\\\.)*\"|'[^']*'|`[^`]*`")
|
||||
minimaxDiagnosticLongToken = regexp.MustCompile(`[A-Za-z0-9_+/=.-]{32,}`)
|
||||
)
|
||||
|
||||
// Preserve the upstream validation explanation, but remove request content,
|
||||
// credentials, URLs and arbitrary quoted values before it reaches logs or DB.
|
||||
func minimaxSafeFailureDetail(responseBody, requestBody []byte, apiKey string) string {
|
||||
root := record(responseBody)
|
||||
message := stringValue(object(root["error"])["message"], root["message"])
|
||||
if message == "" {
|
||||
return ""
|
||||
}
|
||||
privateValues := []string{apiKey}
|
||||
request := record(requestBody)
|
||||
content, _ := request["content"].([]any)
|
||||
for _, item := range content {
|
||||
entry := object(item)
|
||||
privateValues = append(privateValues, stringValue(entry["text"]))
|
||||
for _, kind := range []string{"image_url", "video_url", "audio_url"} {
|
||||
privateValues = append(privateValues, stringValue(object(entry[kind])["url"]))
|
||||
}
|
||||
}
|
||||
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 {
|
||||
message = string(runes[:512]) + "...[truncated]"
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func logMinimaxFailedRequest(request Request, config Config, err error) {
|
||||
var failure *ProviderError
|
||||
if !errors.As(err, &failure) {
|
||||
return
|
||||
}
|
||||
// All strings except the request ID are fixed enums. In particular, never
|
||||
// log custom endpoints, prompts or even the hosts of reference image URLs.
|
||||
endpointClass := "custom"
|
||||
basePathClass := "custom"
|
||||
if endpoint, parseErr := url.Parse(config.BaseURL); parseErr == nil {
|
||||
if endpoint.Hostname() == "api.minimax.cn" {
|
||||
endpointClass = "official-cn"
|
||||
}
|
||||
switch strings.Trim(endpoint.Path, "/") {
|
||||
case "":
|
||||
basePathClass = "root"
|
||||
case "v1", "v2":
|
||||
basePathClass = "version-prefix"
|
||||
}
|
||||
}
|
||||
imageItems := 0
|
||||
if len(request.Materials) > 0 || len(request.InputURLs) > 0 {
|
||||
imageItems = 1
|
||||
}
|
||||
duration := "invalid"
|
||||
if encoded, encodeErr := json.Marshal(request.Settings["duration"]); encodeErr == nil {
|
||||
if value, parseErr := strconv.Atoi(string(encoded)); parseErr == nil && value >= 4 && value <= 15 {
|
||||
duration = strconv.Itoa(value)
|
||||
}
|
||||
}
|
||||
log.Printf(
|
||||
"zhinian-api MiniMax submit request summary requestId=%q model=%q resolution=%q duration=%s ratio=%q textItems=1 promptChars=%d imageItems=%d endpointClass=%s basePathClass=%s",
|
||||
failure.RequestID, minimaxDiagnosticValue(requestModel(request, config.Model)),
|
||||
minimaxDiagnosticValue(stringValue(request.Settings["resolution"])), duration,
|
||||
minimaxDiagnosticValue(stringValue(request.Settings["ratio"])), utf8.RuneCountInString(request.Prompt),
|
||||
imageItems, endpointClass, basePathClass,
|
||||
)
|
||||
}
|
||||
|
||||
func minimaxDiagnosticValue(value string) string {
|
||||
switch value {
|
||||
case MinimaxH3Model, "MiniMax-H3-Max", "768P", "2K", "480P", "adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16",
|
||||
"model", "content", "text", "prompt", "resolution", "duration", "ratio", "image_url", "video_url", "audio_url", "role", "first_frame", "last_frame", "aigc_watermark":
|
||||
return value
|
||||
default:
|
||||
return "unrecognized"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestMinimaxPreservesRejectionReasonForPersistedTextVideoRequest(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
previousOutput := log.Writer()
|
||||
log.SetOutput(&output)
|
||||
t.Cleanup(func() { log.SetOutput(previousOutput) })
|
||||
|
||||
var request Request
|
||||
if err := json.Unmarshal([]byte(`{"model":"MiniMax-H3","prompt":"一段盛开的烟花","settings":{"ratio":"1:1","duration":4,"resolution":"768P"},"capability":"video.generate"}`), &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v2/video_generation" {
|
||||
t.Fatalf("unexpected endpoint: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"model": "MiniMax-H3", "resolution": "768P", "duration": float64(4), "ratio": "1:1", "aigc_watermark": false,
|
||||
"content": []any{map[string]any{"type": "text", "text": "一段盛开的烟花"}},
|
||||
}
|
||||
if !reflect.DeepEqual(payload, want) {
|
||||
t.Fatalf("wire payload=%#v want=%#v", payload, want)
|
||||
}
|
||||
// A synthetic validation response: this does not assert that 4 seconds
|
||||
// is unsupported by the live API, only that its explanation survives.
|
||||
return &http.Response{StatusCode: 400, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":{"type":"bad_request_error","message":"invalid params, duration is not supported (2013)"},"request_id":"request-minimax-1"}`))}, nil
|
||||
})
|
||||
adapter := NewMinimax(Config{BaseURL: "https://api.minimax.cn", APIKey: "private-api-key"}, client)
|
||||
_, err := adapter.Submit(context.Background(), request)
|
||||
var failure *ProviderError
|
||||
if !errors.As(err, &failure) || failure.Code != "2013" || failure.RequestID != "request-minimax-1" || failure.Detail != "invalid params, duration is not supported (2013)" {
|
||||
t.Fatalf("failure=%#v err=%v", failure, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "invalid params") {
|
||||
t.Fatalf("generic error exposed diagnostic: %v", err)
|
||||
}
|
||||
for _, expected := range []string{`detail="invalid params, duration is not supported (2013)"`, `requestId="request-minimax-1"`, `model="MiniMax-H3"`, `resolution="768P"`, "duration=4", `ratio="1:1"`, "promptChars=7", "imageItems=0", "endpointClass=official-cn", "basePathClass=root"} {
|
||||
if !strings.Contains(output.String(), expected) {
|
||||
t.Errorf("missing %q from %s", expected, output.String())
|
||||
}
|
||||
}
|
||||
for _, private := range []string{"一段盛开的烟花", "private-api-key"} {
|
||||
if strings.Contains(output.String(), private) {
|
||||
t.Errorf("log leaked %q", private)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinimaxFailureDetailRedactsEchoedInputsCredentialsAndURLs(t *testing.T) {
|
||||
const prompt = "private prompt\nsecond line"
|
||||
const imageURL = "https://private.test/a.png?Signature=private-signature"
|
||||
request, _ := json.Marshal(map[string]any{"content": []any{
|
||||
map[string]any{"type": "text", "text": prompt},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": imageURL}},
|
||||
}})
|
||||
encodedPrompt, _ := json.Marshal(prompt)
|
||||
response, _ := json.Marshal(map[string]any{"error": map[string]any{"message": "invalid params, 'model' \"MiniMax-H3\" not supported; " + prompt + " " + string(encodedPrompt) + " " + imageURL + " Bearer private-api-key api_key=another-key Authorization: Bearer unrelated-token 'private-user-name' https://other-private.test/a.png?token=other-secret (2013)"}})
|
||||
detail := minimaxSafeFailureDetail(response, request, "private-api-key")
|
||||
for _, expected := range []string{"invalid params", "model", "MiniMax-H3", "not supported", "(2013)", "[redacted]"} {
|
||||
if !strings.Contains(detail, expected) {
|
||||
t.Fatalf("missing %q in %q", expected, detail)
|
||||
}
|
||||
}
|
||||
for _, private := range []string{"private", "second line", "another-key", "unrelated-token", "other-secret", "Signature=", "https://", "Bearer", "\n"} {
|
||||
if strings.Contains(detail, private) {
|
||||
t.Fatalf("detail leaked %q: %s", private, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinimaxFailureDetailIsBoundedAndIgnoresOtherResponseFields(t *testing.T) {
|
||||
response, _ := json.Marshal(map[string]any{"error": map[string]any{"message": strings.Repeat("参数无效 ", 200), "request": "private request body"}, "secret": "private secret"})
|
||||
detail := minimaxSafeFailureDetail(response, nil, "")
|
||||
if utf8.RuneCountInString(detail) > 512+len("...[truncated]") || !strings.HasSuffix(detail, "...[truncated]") || strings.Contains(detail, "private") {
|
||||
t.Fatalf("unsafe/unbounded detail: %s", detail)
|
||||
}
|
||||
if detail := minimaxSafeFailureDetail([]byte("not json"), nil, ""); detail != "" {
|
||||
t.Fatalf("invalid JSON detail=%q", detail)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,11 @@ type ProviderError struct {
|
||||
Operation string
|
||||
Status int
|
||||
Code string
|
||||
Cause error
|
||||
RequestID string
|
||||
// Detail is opt-in, bounded and redacted by the provider-specific adapter.
|
||||
// Error() deliberately never includes it.
|
||||
Detail string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ProviderError) Error() string {
|
||||
@@ -125,14 +129,15 @@ func (e *ProviderError) Error() string {
|
||||
func (e *ProviderError) Unwrap() error { return e.Cause }
|
||||
|
||||
type httpAdapter struct {
|
||||
name string
|
||||
config Config
|
||||
client HTTPClient
|
||||
submitPath func(Request) string
|
||||
queryPath func(string) string
|
||||
payload func(Request) any
|
||||
headers func(*http.Request)
|
||||
decode func([]byte) Result
|
||||
name string
|
||||
config Config
|
||||
client HTTPClient
|
||||
submitPath func(Request) string
|
||||
queryPath func(string) string
|
||||
payload func(Request) any
|
||||
headers func(*http.Request)
|
||||
decode func([]byte) Result
|
||||
failureDetail func(responseBody, requestBody []byte) string
|
||||
}
|
||||
|
||||
func (a *httpAdapter) submit(ctx context.Context, input Request) (Result, error) {
|
||||
@@ -206,6 +211,11 @@ 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.
|
||||
if operation == "submit" && a.failureDetail != nil {
|
||||
detail = a.failureDetail(raw, body)
|
||||
}
|
||||
logHTTPProviderFailure(httpProviderDiagnostic{
|
||||
Provider: a.name,
|
||||
Operation: operation,
|
||||
@@ -215,8 +225,9 @@ func (a *httpAdapter) call(ctx context.Context, method, path string, body []byte
|
||||
ErrorType: errorType,
|
||||
ErrorClass: "service",
|
||||
ElapsedMS: elapsedMilliseconds(startedAt),
|
||||
Detail: detail,
|
||||
})
|
||||
return Result{}, &ProviderError{Operation: a.name + " " + operation, Status: resp.StatusCode, Code: code}
|
||||
return Result{}, &ProviderError{Operation: a.name + " " + operation, Status: resp.StatusCode, Code: code, RequestID: requestID, Detail: detail}
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
logHTTPProviderFailure(httpProviderDiagnostic{
|
||||
@@ -238,9 +249,18 @@ type httpProviderDiagnostic struct {
|
||||
ErrorType string
|
||||
ErrorClass string
|
||||
ElapsedMS int64
|
||||
Detail string
|
||||
}
|
||||
|
||||
func logHTTPProviderFailure(diagnostic httpProviderDiagnostic) {
|
||||
if diagnostic.Detail != "" {
|
||||
log.Printf(
|
||||
"zhinian-api provider operation failed provider=%s operation=%s status=%d code=%q requestId=%q errorType=%q errorClass=%s elapsedMs=%d detail=%q",
|
||||
diagnostic.Provider, diagnostic.Operation, diagnostic.Status, diagnostic.Code, diagnostic.RequestID,
|
||||
diagnostic.ErrorType, diagnostic.ErrorClass, diagnostic.ElapsedMS, diagnostic.Detail,
|
||||
)
|
||||
return
|
||||
}
|
||||
log.Printf(
|
||||
"zhinian-api provider operation failed provider=%s operation=%s status=%d code=%q requestId=%q errorType=%q errorClass=%s elapsedMs=%d",
|
||||
diagnostic.Provider,
|
||||
@@ -269,6 +289,9 @@ func inspectHTTPProviderFailure(raw []byte, headers http.Header) (code, requestI
|
||||
metadataError["code"], metadataError["Code"],
|
||||
root["code"], root["Code"],
|
||||
), 64)
|
||||
if code == "" {
|
||||
code = parenthesizedNumericCode(stringValue(providerError["message"], root["message"]))
|
||||
}
|
||||
requestID = safeHTTPProviderDiagnosticToken(first(
|
||||
providerError["request_id"], providerError["requestId"], providerError["RequestId"], providerError["RequestID"],
|
||||
root["request_id"], root["requestId"], root["RequestId"], root["RequestID"],
|
||||
@@ -283,6 +306,24 @@ func inspectHTTPProviderFailure(raw []byte, headers http.Header) (code, requestI
|
||||
return code, requestID, errorType
|
||||
}
|
||||
|
||||
func parenthesizedNumericCode(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if len(message) < 3 || message[len(message)-1] != ')' {
|
||||
return ""
|
||||
}
|
||||
start := strings.LastIndexByte(message, '(')
|
||||
if start < 0 || start == len(message)-2 {
|
||||
return ""
|
||||
}
|
||||
code := message[start+1 : len(message)-1]
|
||||
for _, character := range code {
|
||||
if character < '0' || character > '9' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return safeHTTPProviderDiagnosticToken(code, 16)
|
||||
}
|
||||
|
||||
func safeHTTPProviderDiagnosticToken(value any, maxLength int) string {
|
||||
var token string
|
||||
switch typed := value.(type) {
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestHTTPAdaptersMapRequestsAndResponses(t *testing.T) {
|
||||
{"evolink", NewEvoLink(Config{BaseURL: "https://e.test", APIKey: "secret", Model: "gpt-image-2"}, nil), "/v1/images/generations", "/v1/tasks/task-1", `{"id":"task-1","status":"completed","results":[{"url":"https://cdn.test/a.png"}]}`},
|
||||
{"bailian", NewBailian(Config{BaseURL: "https://b.test", APIKey: "secret", Model: "wan"}, nil), "/api/v1/services/aigc/image-generation/generation", "/api/v1/tasks/task-1", `{"output":{"task_id":"task-1","task_status":"SUCCEEDED","results":[{"url":"https://cdn.test/a.png"}]}}`},
|
||||
{"seedance", NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "seed"}, nil), "/api/v3/contents/generations/tasks", "/api/v3/contents/generations/tasks/task-1", `{"id":"task-1","status":"succeeded","content":{"video_url":"https://cdn.test/a.mp4"}}`},
|
||||
{"minimax", NewMinimax(Config{BaseURL: "https://m.test", APIKey: "secret", Model: MinimaxH3Model}, nil), "/v2/video_generation", "/v2/query/video_generation/task-1", `{"task":{"id":"task-1","status":"succeeded","content":{"url":"https://cdn.test/a.mp4"}}}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -52,6 +53,8 @@ func TestHTTPAdaptersMapRequestsAndResponses(t *testing.T) {
|
||||
a.client = client
|
||||
case *Seedance:
|
||||
a.client = client
|
||||
case *Minimax:
|
||||
a.client = client
|
||||
}
|
||||
submitted, err := tt.adapter.Submit(context.Background(), Request{Capability: "image.generate", Prompt: "hello"})
|
||||
if err != nil || submitted.TaskID != "task-1" {
|
||||
@@ -65,6 +68,50 @@ func TestHTTPAdaptersMapRequestsAndResponses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinimaxH3PayloadAndUsageMatchOfficialV2Contract(t *testing.T) {
|
||||
var calls int
|
||||
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if request.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Fatalf("authorization=%q", request.Header.Get("Authorization"))
|
||||
}
|
||||
if calls == 1 {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := body["content"].([]any)
|
||||
image := content[1].(map[string]any)
|
||||
imageURL := image["image_url"].(map[string]any)
|
||||
if body["model"] != MinimaxH3Model || body["resolution"] != "2K" || body["duration"] != float64(8) || body["ratio"] != "adaptive" || body["aigc_watermark"] != false || len(content) != 2 || image["role"] != "first_frame" || imageURL["url"] != "https://cdn.test/first.png" {
|
||||
t.Fatalf("body=%#v", body)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"task_id":"task-h3"}`)), Header: http.Header{}}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"task":{"id":"task-h3","model":"MiniMax-H3","status":"succeeded","content":{"url":"https://cdn.test/h3.mp4"},"resolution":"2K","duration":8,"usage":{"total_seconds":8,"input_seconds":0,"output_seconds":8,"input_image_count":1,"completion_tokens":1234},"ratio":"16:9"}}`)), Header: http.Header{}}, nil
|
||||
})
|
||||
adapter := NewMinimax(Config{BaseURL: "https://m.test", APIKey: "secret", Model: MinimaxH3Model}, client)
|
||||
submitted, err := adapter.Submit(context.Background(), Request{
|
||||
Capability: "video.generate", Prompt: "animate", Materials: []Material{{URL: "https://cdn.test/first.png", Type: MaterialImage}},
|
||||
Settings: map[string]any{"resolution": "2K", "duration": float64(8), "ratio": "adaptive"},
|
||||
})
|
||||
if err != nil || submitted.TaskID != "task-h3" || submitted.Status != StatusQueued {
|
||||
t.Fatalf("submitted=%#v err=%v", submitted, err)
|
||||
}
|
||||
queried, err := adapter.Query(context.Background(), submitted.TaskID)
|
||||
if err != nil || queried.Status != StatusSucceeded || !reflect.DeepEqual(queried.OutputURLs, []string{"https://cdn.test/h3.mp4"}) || queried.Usage["outputSeconds"] != 8 || queried.Usage["inputImageCount"] != 1 {
|
||||
t.Fatalf("queried=%#v err=%v", queried, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinimaxErrorDiagnosticsExtractSafeNumericCode(t *testing.T) {
|
||||
raw := []byte(`{"type":"error","error":{"type":"insufficient_balance_error","message":"insufficient balance (1008)","http_code":"402"},"request_id":"request-safe"}`)
|
||||
code, requestID, errorType := inspectHTTPProviderFailure(raw, http.Header{})
|
||||
if code != "1008" || requestID != "request-safe" || errorType != "insufficient_balance_error" {
|
||||
t.Fatalf("diagnostics=%q/%q/%q", code, requestID, errorType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBailianOnlyEnablesAsyncModeWhenSubmitting(t *testing.T) {
|
||||
var requests []*http.Request
|
||||
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
|
||||
Reference in new issue
Block a user