fix(gemini): 移除空返图占位并暴露失败原因
Gemini 图片响应未包含可提取资源时,不再返回伪造占位图,改为非重试失败并保留安全拦截、候选结束状态和上游错误等结构化诊断。\n\n同步将诊断写入 HTTP 错误、任务结果、attempt 指标和日志,确保任务失败时不结算,并补充单元及 PostgreSQL 验收测试。\n\n验证:gofmt、go test ./... -count=1、go vet ./...、迁移安全检查、pnpm openapi。
This commit is contained in:
@@ -1533,6 +1533,94 @@ func TestGeminiClientImageGenerateBuildsNativeImageBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiClientImageGenerateRejectsPromptBlockedWithoutImage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Request-Id", "gemini-blocked-request")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"promptFeedback": map[string]any{
|
||||
"blockReason": "SAFETY",
|
||||
"blockReasonMessage": "The request was blocked by the image safety policy.",
|
||||
},
|
||||
"usageMetadata": map[string]any{"promptTokenCount": 12, "totalTokenCount": 12},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "images.generations",
|
||||
ModelType: "image_generate",
|
||||
Model: "gemini-image",
|
||||
Body: map[string]any{"model": "gemini-image", "prompt": "blocked prompt"},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ProviderModelName: "gemini-image",
|
||||
ModelType: "image_generate",
|
||||
Credentials: map[string]any{"apiKey": "gemini-key"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("missing Gemini image must fail instead of returning a placeholder")
|
||||
}
|
||||
if ErrorCode(err) != "gemini_image_output_missing" || IsRetryable(err) {
|
||||
t.Fatalf("unexpected missing image error: code=%q retryable=%v err=%v", ErrorCode(err), IsRetryable(err), err)
|
||||
}
|
||||
meta := ErrorResponseMetadata(err)
|
||||
if meta.RequestID != "gemini-blocked-request" || meta.ResponseDurationMS < 0 {
|
||||
t.Fatalf("missing image error lost response metadata: %+v", meta)
|
||||
}
|
||||
details := ErrorDetails(err)
|
||||
if details["reason"] != "prompt_blocked" || details["promptBlockReason"] != "SAFETY" || details["candidateCount"] != 0 {
|
||||
t.Fatalf("unexpected prompt block details: %+v", details)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "blockReason=SAFETY") || strings.Contains(err.Error(), "gemini-image-placeholder") {
|
||||
t.Fatalf("missing image error should explain the upstream block without a placeholder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiMissingImageDetailsExplainEmptyImagePayload(t *testing.T) {
|
||||
details := geminiMissingImageDetails(map[string]any{
|
||||
"candidates": []any{map[string]any{
|
||||
"finishReason": "STOP",
|
||||
"content": map[string]any{"parts": []any{
|
||||
map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": ""}},
|
||||
map[string]any{"text": "The model completed without producing an image."},
|
||||
}},
|
||||
}},
|
||||
})
|
||||
message := geminiMissingImageMessage(details)
|
||||
|
||||
if details["reason"] != "empty_image_payload" || details["candidateCount"] != 1 || details["partCount"] != 2 {
|
||||
t.Fatalf("unexpected empty image payload details: %+v", details)
|
||||
}
|
||||
if !strings.Contains(message, "missing inline data or file URI") ||
|
||||
!strings.Contains(message, "finishReason=STOP") ||
|
||||
!strings.Contains(message, "The model completed without producing an image.") {
|
||||
t.Fatalf("missing image message should preserve the concrete extraction reason: %s", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiMissingImageDetailsPreserveUpstreamErrorEnvelope(t *testing.T) {
|
||||
details := geminiMissingImageDetails(map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": 500,
|
||||
"status": "INTERNAL",
|
||||
"message": "image backend returned no generated asset",
|
||||
},
|
||||
})
|
||||
message := geminiMissingImageMessage(details)
|
||||
|
||||
if details["reason"] != "upstream_error" ||
|
||||
details["upstreamErrorCode"] != "500" ||
|
||||
details["upstreamErrorStatus"] != "INTERNAL" {
|
||||
t.Fatalf("unexpected upstream error details: %+v", details)
|
||||
}
|
||||
if !strings.Contains(message, "upstreamErrorCode=500") ||
|
||||
!strings.Contains(message, "upstreamErrorStatus=INTERNAL") ||
|
||||
!strings.Contains(message, "image backend returned no generated asset") {
|
||||
t.Fatalf("upstream error message should remain diagnostic: %s", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiGenerationConfigInitializesMissingImageConfig(t *testing.T) {
|
||||
config := geminiGenerationConfig(map[string]any{
|
||||
"aspect_ratio": "16:9",
|
||||
|
||||
@@ -58,7 +58,13 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt)
|
||||
}
|
||||
output := geminiResult(request, result)
|
||||
output, err := geminiResult(request, result)
|
||||
if err != nil {
|
||||
if requestID == "" {
|
||||
requestID = firstNonEmptyString(result["responseId"], result["response_id"])
|
||||
}
|
||||
return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt)
|
||||
}
|
||||
if requestID == "" {
|
||||
requestID = requestIDFromResult(output)
|
||||
}
|
||||
@@ -631,18 +637,24 @@ func geminiToolsFromOpenAITools(value any) []any {
|
||||
return []any{map[string]any{"functionDeclarations": declarations}}
|
||||
}
|
||||
|
||||
func geminiResult(request Request, raw map[string]any) map[string]any {
|
||||
func geminiResult(request Request, raw map[string]any) (map[string]any, error) {
|
||||
if geminiImageModelType(request.ModelType) {
|
||||
data := geminiImageData(raw)
|
||||
if len(data) == 0 {
|
||||
data = []any{map[string]any{"url": "/static/provider/gemini-image-placeholder.png"}}
|
||||
details := geminiMissingImageDetails(raw)
|
||||
return nil, &ClientError{
|
||||
Code: "gemini_image_output_missing",
|
||||
Message: geminiMissingImageMessage(details),
|
||||
Details: details,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"id": "gemini-image",
|
||||
"created": nowUnix(),
|
||||
"model": request.Model,
|
||||
"data": data,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
message, finishReason := geminiChatMessage(raw)
|
||||
return map[string]any{
|
||||
@@ -656,7 +668,7 @@ func geminiResult(request Request, raw map[string]any) map[string]any {
|
||||
"message": message,
|
||||
}},
|
||||
"usage": geminiUsageMap(raw),
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func textFromMessages(body map[string]any) string {
|
||||
@@ -771,6 +783,250 @@ func geminiImageData(raw map[string]any) []any {
|
||||
return out
|
||||
}
|
||||
|
||||
func geminiMissingImageDetails(raw map[string]any) map[string]any {
|
||||
details := map[string]any{
|
||||
"provider": "gemini",
|
||||
"resourceType": "image",
|
||||
}
|
||||
upstreamError, _ := raw["error"].(map[string]any)
|
||||
upstreamErrorCode := stringFromPathValue(upstreamError["code"])
|
||||
upstreamErrorStatus := strings.TrimSpace(stringFromAny(upstreamError["status"]))
|
||||
upstreamErrorMessage := compactGeminiDiagnosticText(stringFromAny(upstreamError["message"]), 320)
|
||||
if upstreamErrorCode != "" {
|
||||
details["upstreamErrorCode"] = upstreamErrorCode
|
||||
}
|
||||
if upstreamErrorStatus != "" {
|
||||
details["upstreamErrorStatus"] = upstreamErrorStatus
|
||||
}
|
||||
if upstreamErrorMessage != "" {
|
||||
details["upstreamErrorMessage"] = upstreamErrorMessage
|
||||
}
|
||||
promptFeedback, _ := raw["promptFeedback"].(map[string]any)
|
||||
if promptFeedback == nil {
|
||||
promptFeedback, _ = raw["prompt_feedback"].(map[string]any)
|
||||
}
|
||||
promptBlockReason := firstNonEmptyString(promptFeedback["blockReason"], promptFeedback["block_reason"])
|
||||
promptBlockMessage := compactGeminiDiagnosticText(firstNonEmptyString(
|
||||
promptFeedback["blockReasonMessage"],
|
||||
promptFeedback["block_reason_message"],
|
||||
promptFeedback["message"],
|
||||
), 320)
|
||||
if promptBlockReason != "" {
|
||||
details["promptBlockReason"] = promptBlockReason
|
||||
}
|
||||
if promptBlockMessage != "" {
|
||||
details["promptBlockMessage"] = promptBlockMessage
|
||||
}
|
||||
|
||||
candidates, _ := raw["candidates"].([]any)
|
||||
details["candidateCount"] = len(candidates)
|
||||
finishReasons := make([]string, 0)
|
||||
safetyCategories := make([]string, 0)
|
||||
partKinds := make([]string, 0)
|
||||
providerMessages := make([]string, 0)
|
||||
partCount := 0
|
||||
emptyImagePayload := false
|
||||
for _, rawCandidate := range candidates {
|
||||
candidate, _ := rawCandidate.(map[string]any)
|
||||
if finishReason := strings.TrimSpace(stringFromAny(firstPresent(candidate["finishReason"], candidate["finish_reason"]))); finishReason != "" {
|
||||
finishReasons = appendGeminiDiagnosticValue(finishReasons, finishReason)
|
||||
}
|
||||
safetyRatings, _ := candidate["safetyRatings"].([]any)
|
||||
if safetyRatings == nil {
|
||||
safetyRatings, _ = candidate["safety_ratings"].([]any)
|
||||
}
|
||||
for _, rawRating := range safetyRatings {
|
||||
rating, _ := rawRating.(map[string]any)
|
||||
blocked, _ := firstPresent(rating["blocked"], rating["isBlocked"], rating["is_blocked"]).(bool)
|
||||
if !blocked {
|
||||
continue
|
||||
}
|
||||
if category := strings.TrimSpace(stringFromAny(rating["category"])); category != "" {
|
||||
safetyCategories = appendGeminiDiagnosticValue(safetyCategories, category)
|
||||
}
|
||||
}
|
||||
content, _ := candidate["content"].(map[string]any)
|
||||
parts, _ := content["parts"].([]any)
|
||||
partCount += len(parts)
|
||||
for _, rawPart := range parts {
|
||||
part, _ := rawPart.(map[string]any)
|
||||
if text := compactGeminiDiagnosticText(stringFromAny(part["text"]), 320); text != "" {
|
||||
partKinds = appendGeminiDiagnosticValue(partKinds, "text")
|
||||
providerMessages = appendGeminiDiagnosticValue(providerMessages, text)
|
||||
}
|
||||
inline, inlinePresent := part["inlineData"].(map[string]any)
|
||||
if !inlinePresent {
|
||||
inline, inlinePresent = part["inline_data"].(map[string]any)
|
||||
}
|
||||
if inlinePresent {
|
||||
partKinds = appendGeminiDiagnosticValue(partKinds, "inlineData")
|
||||
if strings.TrimSpace(stringFromAny(inline["data"])) == "" {
|
||||
emptyImagePayload = true
|
||||
}
|
||||
}
|
||||
fileData, filePresent := part["fileData"].(map[string]any)
|
||||
if !filePresent {
|
||||
fileData, filePresent = part["file_data"].(map[string]any)
|
||||
}
|
||||
if filePresent {
|
||||
partKinds = appendGeminiDiagnosticValue(partKinds, "fileData")
|
||||
if firstNonEmptyString(fileData["fileUri"], fileData["file_uri"], fileData["uri"]) == "" {
|
||||
emptyImagePayload = true
|
||||
}
|
||||
}
|
||||
for _, item := range []struct {
|
||||
key string
|
||||
kind string
|
||||
}{
|
||||
{key: "thought", kind: "thought"},
|
||||
{key: "functionCall", kind: "functionCall"},
|
||||
{key: "function_call", kind: "functionCall"},
|
||||
{key: "executableCode", kind: "executableCode"},
|
||||
{key: "executable_code", kind: "executableCode"},
|
||||
{key: "codeExecutionResult", kind: "codeExecutionResult"},
|
||||
{key: "code_execution_result", kind: "codeExecutionResult"},
|
||||
} {
|
||||
if _, ok := part[item.key]; ok {
|
||||
partKinds = appendGeminiDiagnosticValue(partKinds, item.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
details["partCount"] = partCount
|
||||
if len(finishReasons) > 0 {
|
||||
details["candidateFinishReasons"] = finishReasons
|
||||
}
|
||||
if len(safetyCategories) > 0 {
|
||||
details["blockedSafetyCategories"] = safetyCategories
|
||||
}
|
||||
if len(partKinds) > 0 {
|
||||
details["observedPartKinds"] = partKinds
|
||||
}
|
||||
if len(providerMessages) > 0 {
|
||||
details["providerMessages"] = providerMessages
|
||||
}
|
||||
|
||||
reason := "no_supported_image_parts"
|
||||
switch {
|
||||
case upstreamErrorCode != "" || upstreamErrorStatus != "" || upstreamErrorMessage != "":
|
||||
reason = "upstream_error"
|
||||
case promptBlockReason != "":
|
||||
reason = "prompt_blocked"
|
||||
case len(safetyCategories) > 0 || geminiFinishReasonsIndicateFiltering(finishReasons):
|
||||
reason = "candidate_filtered"
|
||||
case len(candidates) == 0:
|
||||
reason = "no_candidates"
|
||||
case emptyImagePayload:
|
||||
reason = "empty_image_payload"
|
||||
case partCount == 0:
|
||||
reason = "no_content_parts"
|
||||
}
|
||||
details["reason"] = reason
|
||||
return details
|
||||
}
|
||||
|
||||
func geminiMissingImageMessage(details map[string]any) string {
|
||||
message := "Gemini image response contains no extractable image"
|
||||
switch stringFromAny(details["reason"]) {
|
||||
case "upstream_error":
|
||||
message += ": upstream returned an error envelope"
|
||||
case "prompt_blocked":
|
||||
message += ": prompt blocked"
|
||||
case "candidate_filtered":
|
||||
message += ": candidate filtered"
|
||||
case "no_candidates":
|
||||
message += ": upstream response contains no candidates"
|
||||
case "empty_image_payload":
|
||||
message += ": image part is missing inline data or file URI"
|
||||
case "no_content_parts":
|
||||
message += ": candidate content contains no parts"
|
||||
default:
|
||||
message += ": candidate parts contain no supported image resource"
|
||||
}
|
||||
if reason := stringFromAny(details["promptBlockReason"]); reason != "" {
|
||||
message += " (blockReason=" + reason + ")"
|
||||
}
|
||||
if code := stringFromAny(details["upstreamErrorCode"]); code != "" {
|
||||
message += "; upstreamErrorCode=" + code
|
||||
}
|
||||
if status := stringFromAny(details["upstreamErrorStatus"]); status != "" {
|
||||
message += "; upstreamErrorStatus=" + status
|
||||
}
|
||||
if values := geminiDiagnosticStrings(details["candidateFinishReasons"]); len(values) > 0 {
|
||||
message += "; finishReason=" + strings.Join(values, ",")
|
||||
}
|
||||
if values := geminiDiagnosticStrings(details["blockedSafetyCategories"]); len(values) > 0 {
|
||||
message += "; blockedSafetyCategories=" + strings.Join(values, ",")
|
||||
}
|
||||
if values := geminiDiagnosticStrings(details["providerMessages"]); len(values) > 0 {
|
||||
message += "; providerMessage=" + values[0]
|
||||
} else if upstreamMessage := stringFromAny(details["upstreamErrorMessage"]); upstreamMessage != "" {
|
||||
message += "; providerMessage=" + upstreamMessage
|
||||
} else if blockMessage := stringFromAny(details["promptBlockMessage"]); blockMessage != "" {
|
||||
message += "; providerMessage=" + blockMessage
|
||||
}
|
||||
if values := geminiDiagnosticStrings(details["observedPartKinds"]); len(values) > 0 {
|
||||
message += "; observedPartKinds=" + strings.Join(values, ",")
|
||||
}
|
||||
return compactGeminiDiagnosticText(message, 1800)
|
||||
}
|
||||
|
||||
func geminiFinishReasonsIndicateFiltering(values []string) bool {
|
||||
for _, value := range values {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "SAFETY", "IMAGE_SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func geminiDiagnosticStrings(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
values := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if text := strings.TrimSpace(stringFromAny(item)); text != "" {
|
||||
values = append(values, text)
|
||||
}
|
||||
}
|
||||
return values
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func appendGeminiDiagnosticValue(values []string, value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return values
|
||||
}
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
if len(values) >= 8 {
|
||||
return values
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func compactGeminiDiagnosticText(value string, maxRunes int) string {
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
return string(runes[:maxRunes]) + "…"
|
||||
}
|
||||
|
||||
func geminiUsage(raw map[string]any) Usage {
|
||||
usageMap := geminiUsageMap(raw)
|
||||
input := intFromAny(usageMap["prompt_tokens"])
|
||||
|
||||
@@ -127,6 +127,7 @@ type ClientError struct {
|
||||
Code string
|
||||
Message string
|
||||
Param string
|
||||
Details map[string]any
|
||||
StatusCode int
|
||||
RequestID string
|
||||
ResponseStartedAt time.Time
|
||||
@@ -152,6 +153,18 @@ func ErrorParam(err error) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func ErrorDetails(err error) map[string]any {
|
||||
var clientErr *ClientError
|
||||
if !errors.As(err, &clientErr) || len(clientErr.Details) == 0 {
|
||||
return nil
|
||||
}
|
||||
details := make(map[string]any, len(clientErr.Details))
|
||||
for key, value := range clientErr.Details {
|
||||
details[key] = value
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func (e *ClientError) Error() string {
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
)
|
||||
|
||||
func TestRunErrorDetailsExposeStructuredClientFailure(t *testing.T) {
|
||||
err := &clients.ClientError{
|
||||
Code: "gemini_image_output_missing",
|
||||
Message: "Gemini image response contains no extractable image: prompt blocked",
|
||||
Details: map[string]any{
|
||||
"provider": "gemini",
|
||||
"reason": "prompt_blocked",
|
||||
"promptBlockReason": "SAFETY",
|
||||
},
|
||||
}
|
||||
|
||||
details := runErrorDetails(err)
|
||||
if details["provider"] != "gemini" ||
|
||||
details["reason"] != "prompt_blocked" ||
|
||||
details["promptBlockReason"] != "SAFETY" {
|
||||
t.Fatalf("public error details lost Gemini extraction diagnostics: %+v", details)
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,21 @@ type acceptancePlatformModel struct {
|
||||
}
|
||||
|
||||
type acceptanceTaskDetail struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Attempts []struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
FinalChargeAmount float64 `json:"finalChargeAmount"`
|
||||
BillingStatus string `json:"billingStatus"`
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
Attempts []struct {
|
||||
AttemptNo int `json:"attemptNo"`
|
||||
PlatformID string `json:"platformId"`
|
||||
PlatformName string `json:"platformName"`
|
||||
PlatformModel string `json:"platformModelId"`
|
||||
Status string `json:"status"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
DynamicMetrics map[string]any `json:"metrics"`
|
||||
Trace []map[string]any `json:"trace"`
|
||||
} `json:"attempts"`
|
||||
@@ -86,6 +92,7 @@ func TestFailurePolicyHTTPAcceptance(t *testing.T) {
|
||||
fixture.setRunnerPolicy(t, true)
|
||||
|
||||
t.Run("Gemini 429 后轮转且只冷却模型一次", fixture.testGemini429ImageFailover)
|
||||
t.Run("Gemini 空图片响应返回具体原因并失败", fixture.testGeminiMissingImageFails)
|
||||
t.Run("5xx 同平台重试耗尽后降权并轮转", fixture.testServerErrorRetryThenFailover)
|
||||
t.Run("401 立即禁用平台并轮转", fixture.testAuthFailureDisableThenFailover)
|
||||
t.Run("400 参数错误硬停止", fixture.testBadRequestStops)
|
||||
@@ -429,6 +436,66 @@ WHERE model.id = $1::uuid`, modelA.ID).Scan(&modelCooling, &platformCooling); er
|
||||
}
|
||||
}
|
||||
|
||||
func (f *failurePolicyAcceptanceFixture) testGeminiMissingImageFails(t *testing.T) {
|
||||
model := "acceptance-gemini-empty-image-" + f.suffix
|
||||
baseModelID := f.createBaseModel(t, model, "image_generate")
|
||||
gemini := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Request-Id", "acceptance-gemini-empty-image")
|
||||
writeStubJSON(w, http.StatusOK, map[string]any{
|
||||
"promptFeedback": map[string]any{
|
||||
"blockReason": "SAFETY",
|
||||
"blockReasonMessage": "The request was blocked by the image safety policy.",
|
||||
},
|
||||
"usageMetadata": map[string]any{"promptTokenCount": 3, "totalTokenCount": 3},
|
||||
})
|
||||
})
|
||||
defer gemini.close()
|
||||
f.createPlatformModel(t, "gemini-empty-image", "gemini", gemini.server.URL, 10, baseModelID, "gemini-image-controlled", "image_generate", 3, "", nil)
|
||||
|
||||
startedAt := time.Now()
|
||||
status, _, body := f.postJSON(t, "/api/v1/images/generations", map[string]any{
|
||||
"model": model, "prompt": "controlled blocked image",
|
||||
}, nil)
|
||||
if status != http.StatusBadGateway {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
var response struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("decode missing image response: %v body=%s", err, body)
|
||||
}
|
||||
if response.Error.Code != "gemini_image_output_missing" ||
|
||||
!strings.Contains(response.Error.Message, "blockReason=SAFETY") ||
|
||||
response.Error.Details["reason"] != "prompt_blocked" {
|
||||
t.Fatalf("missing image response should expose the concrete reason: %+v", response.Error)
|
||||
}
|
||||
if gemini.calls.Load() != 1 {
|
||||
t.Fatalf("non-retryable missing image response calls=%d, want 1", gemini.calls.Load())
|
||||
}
|
||||
detail := f.loadTask(t, f.taskIDForModel(t, model, startedAt))
|
||||
if detail.Status != "failed" ||
|
||||
detail.ErrorCode != "gemini_image_output_missing" ||
|
||||
!strings.Contains(detail.ErrorMessage, "blockReason=SAFETY") ||
|
||||
detail.FinalChargeAmount != 0 {
|
||||
t.Fatalf("missing image task should fail without charge: %+v", detail)
|
||||
}
|
||||
if len(detail.Attempts) != 1 ||
|
||||
detail.Attempts[0].Status != "failed" ||
|
||||
detail.Attempts[0].ErrorCode != "gemini_image_output_missing" ||
|
||||
!strings.Contains(detail.Attempts[0].ErrorMessage, "blockReason=SAFETY") {
|
||||
t.Fatalf("missing image attempt should be recorded as failed: %+v", detail.Attempts)
|
||||
}
|
||||
errorDetails, _ := detail.Metrics["errorDetails"].(map[string]any)
|
||||
if errorDetails["reason"] != "prompt_blocked" || errorDetails["promptBlockReason"] != "SAFETY" {
|
||||
t.Fatalf("missing image task lost diagnostic details: %+v", detail.Metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *failurePolicyAcceptanceFixture) testServerErrorRetryThenFailover(t *testing.T) {
|
||||
model := "acceptance-5xx-" + f.suffix
|
||||
baseModelID := f.createBaseModel(t, model, "text_generate")
|
||||
|
||||
@@ -1710,6 +1710,9 @@ func runErrorDetails(err error) map[string]any {
|
||||
if detail := store.ModelCandidateErrorDetails(err); len(detail) > 0 {
|
||||
return detail
|
||||
}
|
||||
if detail := clients.ErrorDetails(err); len(detail) > 0 {
|
||||
return detail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,9 @@ func failureMetrics(err error, simulated bool) (string, map[string]any, time.Tim
|
||||
metrics["error"] = err.Error()
|
||||
metrics["errorCategory"] = info.Category
|
||||
metrics["retryable"] = retryable
|
||||
if details := clients.ErrorDetails(err); len(details) > 0 {
|
||||
metrics["errorDetails"] = details
|
||||
}
|
||||
if detail := rateLimitFailureDetail(err); len(detail) > 0 {
|
||||
metrics["rateLimit"] = detail
|
||||
}
|
||||
@@ -288,6 +291,9 @@ func buildFailureResult(code string, message string, requestID string, err error
|
||||
if requestID != "" {
|
||||
errorPayload["requestId"] = requestID
|
||||
}
|
||||
if details := clients.ErrorDetails(err); len(details) > 0 {
|
||||
errorPayload["details"] = details
|
||||
}
|
||||
if detail := rateLimitFailureDetail(err); len(detail) > 0 {
|
||||
errorPayload["rateLimit"] = detail
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package runner
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
@@ -45,3 +46,27 @@ func TestAttemptMetricsIncludesCacheAffinityCoefficient(t *testing.T) {
|
||||
t.Fatalf("expected cache affinity routing diagnostics in attempt metrics, got %+v", metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureRecordIncludesStructuredClientErrorDetails(t *testing.T) {
|
||||
cause := &clients.ClientError{
|
||||
Code: "gemini_image_output_missing",
|
||||
Message: "Gemini image response contains no extractable image: prompt blocked",
|
||||
Details: map[string]any{
|
||||
"provider": "gemini",
|
||||
"reason": "prompt_blocked",
|
||||
"promptBlockReason": "SAFETY",
|
||||
},
|
||||
}
|
||||
|
||||
_, metrics, _, _, _ := failureMetrics(cause, false)
|
||||
details, _ := metrics["errorDetails"].(map[string]any)
|
||||
if details["reason"] != "prompt_blocked" || details["promptBlockReason"] != "SAFETY" {
|
||||
t.Fatalf("failure metrics lost client error details: %+v", metrics)
|
||||
}
|
||||
result := buildFailureResult(cause.Code, cause.Message, "", cause)
|
||||
errorPayload, _ := result["error"].(map[string]any)
|
||||
resultDetails, _ := errorPayload["details"].(map[string]any)
|
||||
if resultDetails["provider"] != "gemini" || resultDetails["reason"] != "prompt_blocked" {
|
||||
t.Fatalf("failure result lost client error details: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1412,6 +1412,20 @@ func (s *Service) runCandidate(
|
||||
response.ResponseDurationMS = 0
|
||||
}
|
||||
}
|
||||
if err != nil && clients.ErrorCode(err) == "gemini_image_output_missing" && s.logger != nil {
|
||||
meta := clients.ErrorResponseMetadata(err)
|
||||
s.logger.Warn("gemini image response contains no extractable resource",
|
||||
"taskID", task.ID,
|
||||
"attempt", attemptNo,
|
||||
"platformID", candidate.PlatformID,
|
||||
"platformModelID", candidate.PlatformModelID,
|
||||
"clientID", candidate.ClientID,
|
||||
"requestID", meta.RequestID,
|
||||
"error_category", clients.ErrorCode(err),
|
||||
"reason", err.Error(),
|
||||
"details", clients.ErrorDetails(err),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
if clients.ErrorResponseMetadata(err).StatusCode > 0 && submissionStatus != "response_received" {
|
||||
if markErr := setSubmissionStatus("response_received"); markErr != nil {
|
||||
|
||||
Reference in New Issue
Block a user