fix(service): 模型错误提示 - 按上游正文归类 400/422 失败原因 (#327)
上游返回 400/422 时,providerUserFacingErrorMessage 只按状态码返回固定文案 「模型服务拒绝了请求,请检查模型和参数」,捕获到的 providerHTTPError.Body 从未参与判断。用户看到的原因与真实原因无关:内容审核、额度不足、模型无权限 都会被提示成参数问题,导致排查方向错误。 providerPayloadErrorMessage 已经实现了正确的归类逻辑,但只在 HTTP 成功后的 正文解析路径上生效,HTTP 失败路径取不到。 本次改动: - 抽出 providerPayloadErrorCategory,归类失败时返回 ok=false,调用方退回原有 通用文案,不因为归类失败而改变现有行为。 - providerUserFacingErrorMessage 仅对 400/422 解析正文。401/403/404/5xx 的正文 可能是网关 HTML、鉴权诊断或含密钥的内部信息,不参与归类,且这些状态码本身 已有更贴切的固定文案。 - 新增真人肖像类目:部分视频模型(如火山方舟 Seedance)会以 InputImageSensitiveContentDetected.PrivacyInformation 拒绝含真人的输入图片, 原先落到默认分支,提示为参数问题。该类目只匹配供应商错误码中的稳定标识 (privacyinformation / sensitivecontentdetected),不扫描自然语言:正文经常 回显用户提示词,按「肖像」「likeness」这类词判断会把普通参数错误误报。 归类结果始终是固定文案,正文只参与匹配,不会进入用户可见错误。请求分析仍按 既有方式记录经过脱敏的响应正文,本次改动没有新增日志路径。 验证:backend gofmt 无输出;go test ./... 除 TestAutoDLPluginArtifact、 TestPluginRuntimeDropsRemovedBundledProtocol、 TestAutoDLPluginPackageInstallsThroughUploadRuntime 外全部通过,这三项在未改动 的 upstream/main 上同样失败。
This commit is contained in:
@@ -242,25 +242,52 @@ func providerUserFacingErrorMessage(err error) string {
|
||||
}
|
||||
var httpErr providerHTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
// 仅对上游参数校验类状态码解析正文。其他状态码的正文可能是网关 HTML、
|
||||
// 鉴权诊断或含密钥的内部信息,归类价值低且更容易误判。
|
||||
switch httpErr.StatusCode {
|
||||
case http.StatusBadRequest, http.StatusUnprocessableEntity:
|
||||
if message, ok := providerPayloadErrorCategory(httpErr.Body); ok {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return httpErr.Error()
|
||||
}
|
||||
return "连接模型服务失败,请检查渠道地址和网络"
|
||||
}
|
||||
|
||||
func providerPayloadErrorMessage(raw string) string {
|
||||
// providerPayloadErrorCategory 把上游失败正文归类为固定的用户可见原因。
|
||||
// 第二个返回值为 false 表示正文无法归类,调用方应退回到更通用的提示,
|
||||
// 不要因为归类失败就把正文本身当作错误信息。
|
||||
// 正文可能包含密钥或内部诊断信息,只能参与归类,不得回传用户或写入日志。
|
||||
func providerPayloadErrorCategory(raw string) (string, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch {
|
||||
case strings.Contains(normalized, "safety"), strings.Contains(normalized, "moderation"), strings.Contains(normalized, "content policy"), strings.Contains(normalized, "blocked"):
|
||||
return "请求内容未通过模型服务安全审核,请调整后重试"
|
||||
case strings.Contains(normalized, "quota"), strings.Contains(normalized, "insufficient"), strings.Contains(normalized, "balance"), strings.Contains(normalized, "billing"):
|
||||
return "模型服务额度不足,请检查渠道余额或配额"
|
||||
case strings.Contains(normalized, "model") && (strings.Contains(normalized, "not found") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "access")):
|
||||
return "模型不存在或当前渠道未获得模型权限"
|
||||
case strings.Contains(normalized, "invalid"), strings.Contains(normalized, "parameter"), strings.Contains(normalized, "argument"):
|
||||
return "模型服务拒绝了请求,请检查模型和参数"
|
||||
default:
|
||||
return "模型服务返回失败,请检查请求内容或渠道配置"
|
||||
if normalized == "" {
|
||||
return "", false
|
||||
}
|
||||
switch {
|
||||
// 真人肖像类目只匹配供应商错误码里的稳定标识,不扫描自然语言。
|
||||
// 正文常常回显用户提示词,"likeness"、"肖像"这类词单独出现并不能证明
|
||||
// 上游是因为真人形象拒绝,按词判断会把普通参数错误误报成肖像问题。
|
||||
// 该类目排在安全审核之前:错误码已经足够具体,比通用审核提示更可行动。
|
||||
case strings.Contains(normalized, "privacyinformation"), strings.Contains(normalized, "sensitivecontentdetected"):
|
||||
return "输入素材疑似包含真人形象,该模型拒绝生成,请更换为非真人素材或改用其他模型", true
|
||||
case strings.Contains(normalized, "safety"), strings.Contains(normalized, "moderation"), strings.Contains(normalized, "content policy"), strings.Contains(normalized, "blocked"):
|
||||
return "请求内容未通过模型服务安全审核,请调整后重试", true
|
||||
case strings.Contains(normalized, "quota"), strings.Contains(normalized, "insufficient"), strings.Contains(normalized, "balance"), strings.Contains(normalized, "billing"):
|
||||
return "模型服务额度不足,请检查渠道余额或配额", true
|
||||
case strings.Contains(normalized, "model") && (strings.Contains(normalized, "not found") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "access")):
|
||||
return "模型不存在或当前渠道未获得模型权限", true
|
||||
case strings.Contains(normalized, "invalid"), strings.Contains(normalized, "parameter"), strings.Contains(normalized, "argument"):
|
||||
return "模型服务拒绝了请求,请检查模型和参数", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func providerPayloadErrorMessage(raw string) string {
|
||||
if message, ok := providerPayloadErrorCategory(raw); ok {
|
||||
return message
|
||||
}
|
||||
return "模型服务返回失败,请检查请求内容或渠道配置"
|
||||
}
|
||||
|
||||
func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string, taskProjectID string, taskType string, fallbackPrompt string, rawInput string) (map[string]interface{}, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -385,6 +386,169 @@ func TestProviderPayloadErrorMessageUsesSafeActionableCategories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderPayloadErrorCategoryFlagsRealPersonRejection(t *testing.T) {
|
||||
raw := `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"The request failed because the input image 'content[1]' may contain real person. Request id: secret-trace"}}`
|
||||
message, ok := providerPayloadErrorCategory(raw)
|
||||
if !ok {
|
||||
t.Fatalf("providerPayloadErrorCategory() ok = false, want true")
|
||||
}
|
||||
if !strings.Contains(message, "真人形象") {
|
||||
t.Fatalf("providerPayloadErrorCategory() = %q, want 真人形象 category", message)
|
||||
}
|
||||
if strings.Contains(message, "secret") || strings.Contains(message, "content[1]") {
|
||||
t.Fatalf("provider payload detail leaked: %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderPayloadErrorCategoryReportsUnclassifiedBodies(t *testing.T) {
|
||||
for _, raw := range []string{"", " ", "trace_id=private internal stack"} {
|
||||
if message, ok := providerPayloadErrorCategory(raw); ok {
|
||||
t.Fatalf("providerPayloadErrorCategory(%q) = %q, want no category", raw, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUserFacingErrorMessageClassifiesRejectedRequestBodies(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "real person rejection",
|
||||
statusCode: http.StatusBadRequest,
|
||||
body: `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"input image may contain real person, secret-trace"}}`,
|
||||
want: "真人形象",
|
||||
},
|
||||
{
|
||||
name: "moderation rejection",
|
||||
statusCode: http.StatusBadRequest,
|
||||
body: `{"error":{"message":"request blocked by content policy, secret-trace"}}`,
|
||||
want: "安全审核",
|
||||
},
|
||||
{
|
||||
name: "unprocessable entity is classified too",
|
||||
statusCode: http.StatusUnprocessableEntity,
|
||||
body: `{"error":{"message":"insufficient balance, secret-trace"}}`,
|
||||
want: "额度不足",
|
||||
},
|
||||
{
|
||||
name: "unclassified body keeps the generic parameter hint",
|
||||
statusCode: http.StatusBadRequest,
|
||||
body: `{"error":{"message":"trace_id=secret-trace"}}`,
|
||||
want: "请检查模型和参数",
|
||||
},
|
||||
{
|
||||
name: "empty body keeps the generic parameter hint",
|
||||
statusCode: http.StatusBadRequest,
|
||||
body: "",
|
||||
want: "请检查模型和参数",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
message := providerUserFacingErrorMessage(providerHTTPError{StatusCode: tt.statusCode, Body: tt.body})
|
||||
if !strings.Contains(message, tt.want) {
|
||||
t.Fatalf("providerUserFacingErrorMessage() = %q, want category %q", message, tt.want)
|
||||
}
|
||||
if strings.Contains(message, "secret-trace") || strings.Contains(message, `{"error"`) {
|
||||
t.Fatalf("provider response body leaked: %q", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUserFacingErrorMessageOnlyClassifiesValidationStatuses(t *testing.T) {
|
||||
// 鉴权失败与网关错误的正文可能是密钥诊断或代理 HTML,不参与归类。
|
||||
for _, statusCode := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusBadGateway} {
|
||||
message := providerUserFacingErrorMessage(providerHTTPError{
|
||||
StatusCode: statusCode,
|
||||
Body: `{"error":{"message":"blocked by content policy, api-key=secret"}}`,
|
||||
})
|
||||
if strings.Contains(message, "安全审核") {
|
||||
t.Fatalf("status %d classified from response body: %q", statusCode, message)
|
||||
}
|
||||
if strings.Contains(message, "secret") || strings.Contains(message, "api-key") {
|
||||
t.Fatalf("status %d leaked response body: %q", statusCode, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUserFacingErrorMessageClassifiesWrappedHTTPErrors(t *testing.T) {
|
||||
wrapped := fmt.Errorf("视频任务创建失败:%w", providerHTTPError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"may contain real person","request_id":"secret-trace"}}`,
|
||||
})
|
||||
message := providerUserFacingErrorMessage(wrapped)
|
||||
if !strings.Contains(message, "真人形象") {
|
||||
t.Fatalf("providerUserFacingErrorMessage() = %q, want 真人形象 category", message)
|
||||
}
|
||||
if strings.Contains(message, "secret-trace") || strings.Contains(message, `{"error"`) {
|
||||
t.Fatalf("provider response body leaked through wrapped error: %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
// 正文经常回显用户提示词。肖像类词汇本身不能触发真人类目,
|
||||
// 否则普通的参数错误或安全审核会被误报成肖像问题。
|
||||
func TestProviderPayloadErrorCategoryIgnoresEchoedPortraitWording(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "echoed chinese portrait prompt stays a parameter error",
|
||||
raw: `{"error":{"message":"invalid parameter: prompt=生成油画肖像"}}`,
|
||||
want: "请检查模型和参数",
|
||||
},
|
||||
{
|
||||
name: "echoed english likeness prompt stays a parameter error",
|
||||
raw: `{"error":{"message":"invalid argument: style=likeness study"}}`,
|
||||
want: "请检查模型和参数",
|
||||
},
|
||||
{
|
||||
name: "moderation wins over echoed real person prompt",
|
||||
raw: `{"error":{"message":"request blocked by content policy: prompt=real person portrait"}}`,
|
||||
want: "安全审核",
|
||||
},
|
||||
{
|
||||
name: "bare real person prose is not classified as likeness",
|
||||
raw: `{"error":{"message":"this model does not support real people yet"}}`,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
message, ok := providerPayloadErrorCategory(tt.raw)
|
||||
if tt.want == "" {
|
||||
if ok {
|
||||
t.Fatalf("providerPayloadErrorCategory() = %q, want no category", message)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("providerPayloadErrorCategory() ok = false, want category %q", tt.want)
|
||||
}
|
||||
if strings.Contains(message, "真人形象") {
|
||||
t.Fatalf("echoed portrait wording misclassified as likeness: %q", message)
|
||||
}
|
||||
if !strings.Contains(message, tt.want) {
|
||||
t.Fatalf("providerPayloadErrorCategory() = %q, want category %q", message, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 供应商错误码与安全审核措辞同时出现时,以更具体的错误码为准。
|
||||
func TestProviderPayloadErrorCategoryPrefersProviderCodeOverModerationWording(t *testing.T) {
|
||||
raw := `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"blocked by content policy"}}`
|
||||
message, ok := providerPayloadErrorCategory(raw)
|
||||
if !ok || !strings.Contains(message, "真人形象") {
|
||||
t.Fatalf("providerPayloadErrorCategory() = %q, ok = %v, want 真人形象 category", message, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNewAPIChannel2ResolutionPreservesDeclaredTiers(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"1440": "1440p",
|
||||
|
||||
Reference in New Issue
Block a user