fix(gemini): 移除空返图占位并暴露失败原因

Gemini 图片响应未包含可提取资源时,不再返回伪造占位图,改为非重试失败并保留安全拦截、候选结束状态和上游错误等结构化诊断。\n\n同步将诊断写入 HTTP 错误、任务结果、attempt 指标和日志,确保任务失败时不结算,并补充单元及 PostgreSQL 验收测试。\n\n验证:gofmt、go test ./... -count=1、go vet ./...、迁移安全检查、pnpm openapi。
This commit is contained in:
2026-07-29 19:50:43 +08:00
parent 012823adff
commit 3886048e0f
9 changed files with 506 additions and 8 deletions
+261 -5
View File
@@ -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"])