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:
2026-08-05 00:40:42 +08:00
parent c79c2a7b44
commit ebdb96e7d7
19 changed files with 1163 additions and 93 deletions
+139 -17
View File
@@ -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 {