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:
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
@@ -191,6 +192,34 @@ func TestEasyAITaskResultResponseMovesImageDataURLToB64JSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponseForwardsSafeUpstreamParameterMessage(t *testing.T) {
|
||||
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
|
||||
legacy := publicerror.Error{
|
||||
Code: "upstream_invalid_request",
|
||||
Message: "The upstream service rejected the request parameters.",
|
||||
Category: "upstream",
|
||||
Source: "upstream",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Version: "v1",
|
||||
}
|
||||
response := easyAITaskResultResponse(store.GatewayTask{
|
||||
ID: "image-error-1",
|
||||
Kind: "images.edits",
|
||||
Status: "failed",
|
||||
ErrorCode: "http_400",
|
||||
ErrorMessage: raw,
|
||||
PublicError: &legacy,
|
||||
})
|
||||
standard, ok := response["error"].(publicerror.Error)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected EasyAI error payload: %+v", response)
|
||||
}
|
||||
upstream, _ := standard.Details["upstreamError"].(map[string]any)
|
||||
if response["message"] != raw || standard.Message != raw || upstream["message"] != raw {
|
||||
t.Fatalf("EasyAI result did not forward safe upstream diagnostics: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) {
|
||||
base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/"
|
||||
task := store.GatewayTask{
|
||||
|
||||
@@ -11,9 +11,7 @@ import (
|
||||
func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
|
||||
if task.Status == "failed" || task.Status == "cancelled" || task.ErrorCode != "" || task.ErrorMessage != "" {
|
||||
value := publicerror.FromFields(task.ErrorCode, firstNonEmpty(task.ErrorMessage, task.Error), taskErrorHTTPStatus(task), false)
|
||||
if task.PublicError != nil && task.PublicError.Code != "" && task.PublicError.Source != "" {
|
||||
value = *task.PublicError
|
||||
}
|
||||
value = mergeStoredPublicError(task.PublicError, value)
|
||||
value = publicerror.WithIDs(value, task.RequestID, task.ID)
|
||||
publicerror.Observe(value)
|
||||
task.PublicError = &value
|
||||
@@ -32,9 +30,7 @@ func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
value := publicerror.FromFields(attempt.ErrorCode, attempt.ErrorMessage, status, attempt.Retryable)
|
||||
if attempt.PublicError != nil && attempt.PublicError.Code != "" && attempt.PublicError.Source != "" {
|
||||
value = *attempt.PublicError
|
||||
}
|
||||
value = mergeStoredPublicError(attempt.PublicError, value)
|
||||
value = publicerror.WithIDs(value, attempt.RequestID, task.ID)
|
||||
attempt.PublicError = &value
|
||||
attempt.ErrorCode = value.Code
|
||||
@@ -44,14 +40,33 @@ func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
|
||||
}
|
||||
|
||||
func publicTaskError(task store.GatewayTask) publicerror.Error {
|
||||
if task.PublicError != nil && task.PublicError.Code != "" && task.PublicError.Source != "" {
|
||||
return publicerror.WithIDs(*task.PublicError, task.RequestID, task.ID)
|
||||
}
|
||||
status := taskErrorHTTPStatus(task)
|
||||
if status <= 0 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
return publicerror.WithIDs(publicerror.FromFields(task.ErrorCode, firstNonEmpty(task.ErrorMessage, task.Error, task.Message), status, false), task.RequestID, task.ID)
|
||||
value := publicerror.FromFields(task.ErrorCode, firstNonEmpty(task.ErrorMessage, task.Error, task.Message), status, false)
|
||||
value = mergeStoredPublicError(task.PublicError, value)
|
||||
return publicerror.WithIDs(value, task.RequestID, task.ID)
|
||||
}
|
||||
|
||||
func mergeStoredPublicError(stored *publicerror.Error, derived publicerror.Error) publicerror.Error {
|
||||
if stored == nil || stored.Code == "" || stored.Source == "" {
|
||||
return derived
|
||||
}
|
||||
value := *stored
|
||||
if len(value.Details) == 0 && len(derived.Details) > 0 {
|
||||
value.Details = derived.Details
|
||||
}
|
||||
if message := safeDerivedUpstreamMessage(derived); message != "" {
|
||||
value.Message = message
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func safeDerivedUpstreamMessage(value publicerror.Error) string {
|
||||
upstream, _ := value.Details["upstreamError"].(map[string]any)
|
||||
message, _ := upstream["message"].(string)
|
||||
return strings.TrimSpace(message)
|
||||
}
|
||||
|
||||
func taskErrorHTTPStatus(task store.GatewayTask) int {
|
||||
@@ -109,6 +124,9 @@ func publicErrorMap(value publicerror.Error) map[string]any {
|
||||
if taskID := strings.TrimSpace(value.TaskID); taskID != "" {
|
||||
out["taskId"] = taskID
|
||||
}
|
||||
if len(value.Details) > 0 {
|
||||
out["details"] = value.Details
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -146,6 +164,11 @@ func safePublicErrorDetails(details map[string]any, value publicerror.Error, inc
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, item := range value.Details {
|
||||
if item != nil {
|
||||
out[key] = item
|
||||
}
|
||||
}
|
||||
if includePublicError {
|
||||
out["publicError"] = value
|
||||
}
|
||||
|
||||
@@ -47,6 +47,25 @@ func TestPublicHTTPErrorReportsSourceAndPreservesSafeUpstreamStatus(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicHTTPErrorIncludesSafeUpstreamParameterMessage(t *testing.T) {
|
||||
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
|
||||
recorder := httptest.NewRecorder()
|
||||
writeProtocolError(recorder, clients.ProtocolOpenAIImages, http.StatusBadRequest, raw, nil, "http_400")
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorPayload := requireObject(t, body["error"])
|
||||
details := requireObject(t, errorPayload["details"])
|
||||
upstream := requireObject(t, details["upstreamError"])
|
||||
if errorPayload["message"] != raw || upstream["message"] != raw || upstream["code"] != "http_400" || upstream["statusCode"] != float64(http.StatusBadRequest) {
|
||||
t.Fatalf("unexpected upstream error details: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) {
|
||||
rawMessage := `404 page not found: {"privateProject":"secret"}`
|
||||
task := store.GatewayTask{
|
||||
@@ -75,6 +94,40 @@ func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicGatewayTaskForwardsSafeUpstreamParameterMessage(t *testing.T) {
|
||||
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
|
||||
legacy := publicerror.Error{
|
||||
Code: "upstream_invalid_request",
|
||||
Message: "The upstream service rejected the request parameters.",
|
||||
Category: "upstream",
|
||||
Source: "upstream",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Version: "v1",
|
||||
}
|
||||
task := store.GatewayTask{
|
||||
ID: "task-parameter-error",
|
||||
Status: "failed",
|
||||
ErrorCode: "http_400",
|
||||
ErrorMessage: raw,
|
||||
PublicError: &legacy,
|
||||
Attempts: []store.TaskAttempt{{
|
||||
AttemptNo: 1,
|
||||
Status: "failed",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
ErrorCode: "http_400",
|
||||
ErrorMessage: raw,
|
||||
PublicError: &legacy,
|
||||
}},
|
||||
}
|
||||
|
||||
public := publicGatewayTask(task)
|
||||
upstream := requireObject(t, public.PublicError.Details["upstreamError"])
|
||||
attemptUpstream := requireObject(t, public.Attempts[0].PublicError.Details["upstreamError"])
|
||||
if public.ErrorMessage != raw || public.Attempts[0].ErrorMessage != raw || upstream["message"] != raw || attemptUpstream["message"] != raw {
|
||||
t.Fatalf("task response did not forward safe upstream diagnostics: %+v", public)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskErrorHTTPStatusRebuildsLegacySnapshotFromAttempt(t *testing.T) {
|
||||
legacy := publicerror.Error{
|
||||
Code: "upstream_request_rejected",
|
||||
|
||||
Reference in New Issue
Block a user