diff --git a/apps/api/internal/clients/clients_test.go b/apps/api/internal/clients/clients_test.go index 1b201f3..5343aec 100644 --- a/apps/api/internal/clients/clients_test.go +++ b/apps/api/internal/clients/clients_test.go @@ -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", diff --git a/apps/api/internal/clients/gemini.go b/apps/api/internal/clients/gemini.go index b3a0e25..7f7ec09 100644 --- a/apps/api/internal/clients/gemini.go +++ b/apps/api/internal/clients/gemini.go @@ -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"]) diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 78fdbad..3f7e379 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -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 diff --git a/apps/api/internal/httpapi/client_error_detail_test.go b/apps/api/internal/httpapi/client_error_detail_test.go new file mode 100644 index 0000000..a4f32e1 --- /dev/null +++ b/apps/api/internal/httpapi/client_error_detail_test.go @@ -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) + } +} diff --git a/apps/api/internal/httpapi/failure_policy_http_acceptance_integration_test.go b/apps/api/internal/httpapi/failure_policy_http_acceptance_integration_test.go index c6bf2d7..2446f1e 100644 --- a/apps/api/internal/httpapi/failure_policy_http_acceptance_integration_test.go +++ b/apps/api/internal/httpapi/failure_policy_http_acceptance_integration_test.go @@ -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") diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index b4c8e8e..7e9a173 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -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 } diff --git a/apps/api/internal/runner/recording.go b/apps/api/internal/runner/recording.go index 0754b0d..6cc4905 100644 --- a/apps/api/internal/runner/recording.go +++ b/apps/api/internal/runner/recording.go @@ -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 } diff --git a/apps/api/internal/runner/recording_test.go b/apps/api/internal/runner/recording_test.go index 1fd6f0d..f5f5506 100644 --- a/apps/api/internal/runner/recording_test.go +++ b/apps/api/internal/runner/recording_test.go @@ -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) + } +} diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index fa1f510..662475b 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -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 {