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",
|
||||
|
||||
Reference in New Issue
Block a user