fix(gemini): 移除空返图占位并暴露失败原因
Gemini 图片响应未包含可提取资源时,不再返回伪造占位图,改为非重试失败并保留安全拦截、候选结束状态和上游错误等结构化诊断。\n\n同步将诊断写入 HTTP 错误、任务结果、attempt 指标和日志,确保任务失败时不结算,并补充单元及 PostgreSQL 验收测试。\n\n验证:gofmt、go test ./... -count=1、go vet ./...、迁移安全检查、pnpm openapi。
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user