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:
Dade
2026-08-27 09:14:51 +08:00
committed by GitHub
parent 1ccbbc5a7b
commit ad9c74ba64
2 changed files with 203 additions and 12 deletions

View File

@@ -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) {

View File

@@ -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",