fix(provider): 修正媒体请求转换与上游错误透传
按上游协议能力延迟处理媒体资源:OpenAI 兼容平台默认使用 multipart,显式配置后才发送 JSON URL;Gemini 官方协议使用 Files API,兼容协议使用内嵌 Base64,并同步覆盖相关媒体客户端。\n\n安全的上游 400/422 原始错误会作为下游 message 返回,同时保留结构化诊断信息和历史任务兼容。\n\n验证:API 全量无缓存测试、go vet、pnpm lint、pnpm test、pnpm build、pnpm openapi、git diff --check。
This commit is contained in:
@@ -90,8 +90,9 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
provisionalRules := make([]parameterCorrectionRule, 0, 2)
|
||||
seenCorrectionErrors := make(map[string]struct{})
|
||||
var resp *http.Response
|
||||
requestClient := httpClient(request.HTTPClient, c.HTTPClient)
|
||||
for correctionAttempt := 0; ; correctionAttempt++ {
|
||||
raw, contentType, payloadErr := openAIRequestPayload(endpointKind, body, request.Candidate)
|
||||
raw, contentType, payloadErr := openAIRequestPayload(ctx, endpointKind, body, request.Candidate)
|
||||
if payloadErr != nil {
|
||||
return Response{}, payloadErr
|
||||
}
|
||||
@@ -105,7 +106,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
if err := notifySubmissionStarted(request); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
resp, requestErr = httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
resp, requestErr = requestClient.Do(req)
|
||||
if requestErr != nil {
|
||||
return Response{}, &ClientError{Code: "network", Message: requestErr.Error(), Retryable: true}
|
||||
}
|
||||
@@ -254,14 +255,16 @@ func normalizeOpenAIImageRequestBody(endpointKind string, body map[string]any, o
|
||||
}
|
||||
}
|
||||
|
||||
func openAIRequestPayload(endpointKind string, body map[string]any, candidate store.RuntimeModelCandidate) ([]byte, string, error) {
|
||||
func openAIRequestPayload(ctx context.Context, endpointKind string, body map[string]any, candidate store.RuntimeModelCandidate) ([]byte, string, error) {
|
||||
if endpointKind != "images.edits" {
|
||||
raw, err := json.Marshal(body)
|
||||
return raw, "application/json", err
|
||||
}
|
||||
if OpenAIImageEditUsesJSONURL(candidate) {
|
||||
return openAIImageEditJSONPayload(body)
|
||||
}
|
||||
var payload bytes.Buffer
|
||||
writer := multipart.NewWriter(&payload)
|
||||
imageFieldName := openAIImageEditFieldName(candidate)
|
||||
images := openAIImageEditValues(firstPresent(body["images"], body["image"]))
|
||||
if len(images) == 0 {
|
||||
return nil, "", &ClientError{
|
||||
@@ -272,8 +275,9 @@ func openAIRequestPayload(endpointKind string, body map[string]any, candidate st
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
imageFieldName := openAIImageEditFieldName(candidate, len(images))
|
||||
for index, value := range images {
|
||||
contentType, image, err := openAIImageEditPayload(value)
|
||||
contentType, image, err := openAIImageEditPayload(ctx, value)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -282,7 +286,7 @@ func openAIRequestPayload(endpointKind string, body map[string]any, candidate st
|
||||
}
|
||||
}
|
||||
if mask := firstPresent(body["mask"], body["mask_image"], body["maskImage"]); mask != nil {
|
||||
contentType, image, err := openAIImageEditPayload(mask)
|
||||
contentType, image, err := openAIImageEditPayload(ctx, mask)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -315,10 +319,93 @@ func openAIRequestPayload(endpointKind string, body map[string]any, candidate st
|
||||
return payload.Bytes(), writer.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
func openAIImageEditFieldName(candidate store.RuntimeModelCandidate) string {
|
||||
// OpenAIImageEditUsesJSONURL reports whether the platform explicitly opts in to
|
||||
// JSON URL requests for image edits. Multipart remains the compatibility-safe
|
||||
// default when the setting is absent or unrecognized.
|
||||
func OpenAIImageEditUsesJSONURL(candidate store.RuntimeModelCandidate) bool {
|
||||
format := strings.ToLower(strings.TrimSpace(stringFromAny(candidate.PlatformConfig["imageEditRequestFormat"])))
|
||||
format = strings.ReplaceAll(format, "-", "_")
|
||||
format = strings.ReplaceAll(format, " ", "_")
|
||||
return format == "json" || format == "json_url"
|
||||
}
|
||||
|
||||
func openAIImageEditJSONPayload(body map[string]any) ([]byte, string, error) {
|
||||
images := openAIImageEditValues(firstPresent(body["images"], body["image"]))
|
||||
if len(images) == 0 {
|
||||
return nil, "", &ClientError{
|
||||
Code: "invalid_parameter",
|
||||
Message: "image is required",
|
||||
Param: "image",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
imageURLs := make([]any, 0, len(images))
|
||||
for _, image := range images {
|
||||
imageURL, err := openAIImageEditURLValue(image, "image")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
imageURLs = append(imageURLs, imageURL)
|
||||
}
|
||||
payload := make(map[string]any, len(body))
|
||||
for key, value := range body {
|
||||
switch key {
|
||||
case "image", "images", "mask", "mask_image", "maskImage":
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(key, "_") || value == nil {
|
||||
continue
|
||||
}
|
||||
payload[key] = value
|
||||
}
|
||||
if len(imageURLs) == 1 {
|
||||
payload["image"] = imageURLs[0]
|
||||
} else {
|
||||
payload["image"] = imageURLs
|
||||
}
|
||||
if mask := firstPresent(body["mask"], body["mask_image"], body["maskImage"]); mask != nil {
|
||||
maskURL, err := openAIImageEditURLValue(mask, "mask")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
payload["mask"] = maskURL
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
return raw, "application/json", err
|
||||
}
|
||||
|
||||
func openAIImageEditURLValue(value any, param string) (string, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range []string{"url", "image_url", "imageUrl"} {
|
||||
if nested := typed[key]; nested != nil {
|
||||
return openAIImageEditURLValue(nested, param)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
raw := strings.TrimSpace(typed)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil && parsed.Host != "" && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")) {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return "", &ClientError{
|
||||
Code: "invalid_parameter",
|
||||
Message: "OpenAI image edit JSON mode requires " + param + " to be an HTTP(S) URL",
|
||||
Param: param,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
func openAIImageEditFieldName(candidate store.RuntimeModelCandidate, imageCount int) string {
|
||||
if configured := strings.TrimSpace(stringFromAny(candidate.PlatformConfig["imageEditMultipartFieldName"])); configured == "image" || configured == "image[]" {
|
||||
return configured
|
||||
}
|
||||
if imageCount > 1 {
|
||||
return "image[]"
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(candidate.BaseURL))
|
||||
if err == nil && strings.EqualFold(parsed.Hostname(), "api.openai.com") {
|
||||
return "image[]"
|
||||
@@ -343,12 +430,12 @@ func openAIImageEditValues(value any) []any {
|
||||
}
|
||||
}
|
||||
|
||||
func openAIImageEditPayload(value any) (string, []byte, error) {
|
||||
func openAIImageEditPayload(ctx context.Context, value any) (string, []byte, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range []string{"data", "b64_json", "base64", "url"} {
|
||||
if nested := typed[key]; nested != nil {
|
||||
return openAIImageEditPayload(nested)
|
||||
return openAIImageEditPayload(ctx, nested)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
@@ -365,14 +452,8 @@ func openAIImageEditPayload(value any) (string, []byte, error) {
|
||||
}
|
||||
contentType = strings.TrimSpace(strings.Split(strings.TrimPrefix(prefix, "data:"), ";")[0])
|
||||
encoded = payload
|
||||
} else if strings.Contains(raw, "://") {
|
||||
return "", nil, &ClientError{
|
||||
Code: "invalid_parameter",
|
||||
Message: "OpenAI image edit input must be hydrated before multipart submission",
|
||||
Param: "image",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Retryable: false,
|
||||
}
|
||||
} else if parsed, parseErr := url.Parse(raw); parseErr == nil && parsed.Host != "" && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")) {
|
||||
return fetchRemoteMediaInputPayload(ctx, raw, 256<<20)
|
||||
}
|
||||
image, err := decodeOpenAIImageEditBase64(encoded)
|
||||
if err != nil {
|
||||
@@ -396,6 +477,47 @@ func openAIImageEditPayload(value any) (string, []byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRemoteMediaInputPayload(ctx context.Context, sourceURL string, maxBytes int64) (string, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, &ClientError{Code: "request_asset_fetch_failed", Message: err.Error(), Retryable: false}
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, &ClientError{Code: "request_asset_fetch_failed", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return "", nil, &ClientError{
|
||||
Code: "request_asset_fetch_failed",
|
||||
Message: resp.Status,
|
||||
StatusCode: resp.StatusCode,
|
||||
Retryable: HTTPRetryable(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 256 << 20
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return "", nil, &ClientError{Code: "request_asset_fetch_failed", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
if int64(len(payload)) > maxBytes {
|
||||
return "", nil, &ClientError{
|
||||
Code: "invalid_parameter",
|
||||
Message: "remote media input exceeds the download limit",
|
||||
Param: "image",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||||
if contentType == "" || contentType == "application/octet-stream" {
|
||||
contentType = strings.TrimSpace(strings.Split(http.DetectContentType(payload), ";")[0])
|
||||
}
|
||||
return contentType, payload, nil
|
||||
}
|
||||
|
||||
func decodeOpenAIImageEditBase64(value string) ([]byte, error) {
|
||||
normalized := strings.Map(func(char rune) rune {
|
||||
switch char {
|
||||
|
||||
Reference in New Issue
Block a user