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
@@ -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")
+3
View File
@@ -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
}