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
+102 -21
View File
@@ -128,6 +128,15 @@ func (s *Service) hydrateProviderRequestAssetRef(ctx context.Context, ref map[st
if err != nil {
return nil, err
}
if requestAssetClientMaterializesRemoteMedia(candidate, path, asset) {
assetURL, accessErr := s.requestAssetAccessURL(ctx, asset)
if accessErr != nil {
return nil, accessErr
}
if requestAssetStringIsHTTPURL(assetURL) {
return assetURL, nil
}
}
switch requestAssetHydrationForField(path, asset, candidate) {
case requestAssetHydrateUnsupported:
return nil, requestAssetUnsupportedInputFormatError(path, asset)
@@ -155,6 +164,9 @@ func (s *Service) hydrateProviderRequestAssetRef(ctx context.Context, ref map[st
if strings.TrimSpace(assetURL) == "" {
return nil, requestAssetExpiredError(asset)
}
if openAIImageEditRequiresJSONURL(candidate) && imageInputFieldNeedsHydration(path) && !requestAssetURLIsPublic(asset.StorageProvider, assetURL) {
return nil, requestAssetPublicURLRequiredError(path)
}
return assetURL, nil
}
@@ -163,8 +175,17 @@ func (s *Service) hydrateProviderRequestAssetString(ctx context.Context, value s
if raw == "" || !imageInputFieldNeedsHydration(path) {
return value, nil
}
if openAIImageEditRequiresJSONURL(candidate) {
if !requestAssetURLIsPublic("", raw) {
return nil, requestAssetPublicURLRequiredError(path)
}
return value, nil
}
if requestAssetClientMaterializesRemoteMedia(candidate, path, store.RequestAsset{URL: raw}) && requestAssetStringIsHTTPURL(raw) {
return value, nil
}
style, ok := requestAssetHydrateUnsupported, false
if geminiVeoRequiresInlineImage(candidate) || openAIImageEditRequiresMultipartBytes(candidate) {
if openAIImageEditRequiresMultipartBytes(candidate) {
style, ok = requestAssetHydrateDataURL, true
} else {
style, ok = requestAssetCapabilityHydrationForMedia("image", candidate, raw, "")
@@ -392,13 +413,22 @@ func requestAssetHydrationForField(path []string, asset store.RequestAsset, cand
return requestAssetHydrateRawBase64
}
if candidate.ModelType == "voice_clone" && voiceCloneAudioFieldNeedsHydration(path, asset) {
if requestAssetStringIsHTTPURL(asset.URL) {
return requestAssetHydrateURL
}
return requestAssetHydrateDataURL
}
if requestAssetMediaKindForHydration(path, asset) == "image" {
if geminiVeoRequiresInlineImage(candidate) {
return requestAssetHydrateDataURL
if requestAssetClientMaterializesRemoteMedia(candidate, path, asset) && requestAssetStringIsHTTPURL(asset.URL) {
return requestAssetHydrateURL
}
if openAIImageEditRequiresJSONURL(candidate) {
return requestAssetHydrateURL
}
if openAIImageEditRequiresMultipartBytes(candidate) {
if requestAssetStringIsHTTPURL(asset.URL) {
return requestAssetHydrateURL
}
return requestAssetHydrateDataURL
}
if style, ok := requestAssetCapabilityHydrationForMedia("image", candidate, asset.URL, asset.StorageProvider); ok {
@@ -409,21 +439,63 @@ func requestAssetHydrationForField(path []string, asset store.RequestAsset, cand
if style := configuredRequestAssetMediaURLHydration(candidate, requestAssetMediaURLKind(path)); style != "" {
return style
}
if providerMediaURLNeedsDataURL(candidate) {
return requestAssetHydrateDataURL
}
}
if requestAssetMediaKindForHydration(path, asset) != "" && strings.EqualFold(strings.TrimSpace(asset.StorageProvider), "local_static") {
return requestAssetHydrateDataURL
}
return requestAssetHydrateURL
}
func openAIImageEditRequiresMultipartBytes(candidate store.RuntimeModelCandidate) bool {
return normalizeProviderKey(candidate.Provider) == "openai" &&
strings.TrimSpace(candidate.ModelType) == "image_edit"
return openAIImageEditCandidate(candidate) && !clients.OpenAIImageEditUsesJSONURL(candidate)
}
func geminiVeoRequiresInlineImage(candidate store.RuntimeModelCandidate) bool {
return normalizeProviderKey(candidate.Provider) == "gemini" &&
strings.Contains(strings.ToLower(strings.TrimSpace(firstNonEmptyString(candidate.ProviderModelName, candidate.ModelName))), "veo")
func openAIImageEditRequiresJSONURL(candidate store.RuntimeModelCandidate) bool {
return openAIImageEditCandidate(candidate) && clients.OpenAIImageEditUsesJSONURL(candidate)
}
func openAIImageEditCandidate(candidate store.RuntimeModelCandidate) bool {
if strings.TrimSpace(candidate.ModelType) != "image_edit" {
return false
}
return normalizeProviderKey(candidate.Provider) == "openai" || normalizeProviderKey(candidate.SpecType) == "openai"
}
func requestAssetClientMaterializesRemoteMedia(candidate store.RuntimeModelCandidate, path []string, asset store.RequestAsset) bool {
mediaKind := requestAssetMediaKindForHydration(path, asset)
if mediaKind == "image" {
if openAIImageEditRequiresMultipartBytes(candidate) || geminiClientMaterializesImages(candidate) || kelingClientMaterializesImages(candidate) {
return true
}
}
return mediaKind == "audio" && strings.TrimSpace(candidate.ModelType) == "voice_clone" && candidateUsesClient(candidate, "minimax")
}
func geminiClientMaterializesImages(candidate store.RuntimeModelCandidate) bool {
return candidateUsesClient(candidate, "gemini", "google_gemini")
}
func kelingClientMaterializesImages(candidate store.RuntimeModelCandidate) bool {
return strings.TrimSpace(candidate.ModelType) != "" && candidateUsesClient(candidate, "keling", "kling")
}
func candidateUsesClient(candidate store.RuntimeModelCandidate, names ...string) bool {
wanted := make(map[string]bool, len(names))
for _, name := range names {
wanted[normalizeProviderKey(name)] = true
}
for _, value := range []string{candidate.SpecType, candidate.Provider} {
if wanted[normalizeProviderKey(value)] {
return true
}
}
if wanted["gemini"] {
provider := normalizeProviderKey(candidate.Provider)
if provider == "gemini_openai" || strings.Contains(strings.ToLower(strings.TrimSpace(candidate.BaseURL)), "generativelanguage.googleapis.com") {
return true
}
}
return false
}
func requestAssetMediaKindForHydration(path []string, asset store.RequestAsset) string {
@@ -594,6 +666,12 @@ func requestAssetStringLooksURL(value string) bool {
strings.HasPrefix(lower, "/static/uploaded/")
}
func requestAssetStringIsHTTPURL(value string) bool {
raw := strings.TrimSpace(value)
parsed, err := url.Parse(raw)
return err == nil && parsed.Host != "" && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https"))
}
func requestAssetURLIsPublic(storageProvider string, value string) bool {
if strings.EqualFold(strings.TrimSpace(storageProvider), "local_static") {
return false
@@ -701,16 +779,6 @@ func requestAssetHydrationStyleFromString(value string) requestAssetHydrationSty
}
}
func providerMediaURLNeedsDataURL(candidate store.RuntimeModelCandidate) bool {
for _, name := range []string{candidate.Provider, candidate.SpecType, candidate.PlatformKey} {
switch normalizeProviderKey(name) {
case "openai", "volces", "volces_openai", "gemini", "vidu":
return true
}
}
return false
}
func normalizeProviderKey(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = strings.ReplaceAll(normalized, "-", "_")
@@ -782,3 +850,16 @@ func requestAssetUnsupportedInputFormatError(path []string, asset store.RequestA
}
return &clients.ClientError{Code: "request_asset_input_format_unsupported", Message: message, Retryable: false}
}
func requestAssetPublicURLRequiredError(path []string) error {
field := strings.Join(path, ".")
if field == "" {
field = "image"
}
return &clients.ClientError{
Code: "request_asset_public_url_required",
Message: "OpenAI image edit JSON mode requires a public HTTP(S) URL for " + field,
Param: field,
Retryable: false,
}
}