refactor: 模型错误解析改为 ErrorMessageMapping 配置驱动
- parseModelError 按模型配置的 ErrorMessageMapping(schema 树)解析错误响应 - 成功判定: code 提取为空 / code 节点配置 defaultValue 且提取值等于它 → 成功 - 未配置 mapping 不识别错误(纯配置驱动), 删硬编码 ModelErrorResp/ModelError1Resp - DTO 加 errorMessageMapping 字段, update.sql 建 error_message_mapping JSONB 列 - 单测: parse_error_test.go 覆盖扁平/嵌套/数组/纯字符串路径/未配置/坏 JSON
This commit is contained in:
@@ -59,18 +59,6 @@ type ModelCallStreamReq struct {
|
||||
BusinessParams map[string]any `json:"businessParams" dc:"业务参数(按业务字段名传,按 RequestBusinessFieldMapping 写入请求体)"`
|
||||
}
|
||||
|
||||
type ModelErrorResp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type ModelError1Resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ModelMsg struct {
|
||||
TaskID int64 `json:"id" dc:"任务ID"`
|
||||
TotalTokens int64 `json:"totalTokens" dc:"总token"`
|
||||
|
||||
@@ -38,6 +38,7 @@ type CreateModelManageReq struct {
|
||||
MinDuration int `json:"minDuration" dc:"最小时长"`
|
||||
MaxDuration int `json:"maxDuration" dc:"最大时长"`
|
||||
LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"`
|
||||
ErrorMessageMapping map[string]any `json:"errorMessageMapping" dc:"错误消息映射(schema 树,解析模型错误用)"`
|
||||
}
|
||||
|
||||
type CreateModelManageRes struct {
|
||||
@@ -73,6 +74,7 @@ type UpdateModelManageReq struct {
|
||||
MinDuration int `json:"minDuration" dc:"最小时长"`
|
||||
MaxDuration int `json:"maxDuration" dc:"最大时长"`
|
||||
LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"`
|
||||
ErrorMessageMapping map[string]any `json:"errorMessageMapping" dc:"错误消息映射(schema 树,解析模型错误用)"`
|
||||
}
|
||||
|
||||
type DeleteModelManageReq struct {
|
||||
|
||||
@@ -246,8 +246,8 @@ LOOP:
|
||||
}
|
||||
pollErrCnt = 0 // 请求成功一次即重置连续失败计数
|
||||
|
||||
// 异常响应识别:兼容 OpenAI 嵌套 error / 扁平 code 两种形态(与任务创建端一致),无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil {
|
||||
// 异常响应识别:按模型 ErrorMessageMapping 解析,无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody, modelInfo.ErrorMessageMapping); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err)
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
|
||||
@@ -45,8 +45,8 @@ func (s *modelTaskStartService) CreateTask(ctx context.Context, req *dto.CallMod
|
||||
}
|
||||
docMsg := new(dto.ModelCallRes)
|
||||
docMsg.TaskId = id
|
||||
// 统一解析模型错误(兼容 OpenAI 嵌套 error / 扁平 code 两种形态),无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil {
|
||||
// 按模型 ErrorMessageMapping 解析错误响应,无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody, modelInfo.ErrorMessageMapping); err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
if docMsg.ErrorMsg != "" {
|
||||
|
||||
+179
-17
@@ -1,32 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"model-gateway/model/dto"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
modelUtils "model-gateway/service/utils"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// parseModelError 解析模型错误响应,返回错误码与错误消息(无错误均返回空串)。
|
||||
// 兼容两种形态(与任务创建端 model_task_start_service.go 一致):
|
||||
// - OpenAI 嵌套 {"error":{"code","message"}} → 取 error.code / error.message
|
||||
// - 扁平 {"code","message"} → code=20000000 视为成功码,不当作错误
|
||||
// parseModelError 按模型配置的 ErrorMessageMapping 解析错误响应,返回错误码与错误消息(无错误均返回空串)。
|
||||
// ErrorMessageMapping 为 schema 树(与请求模板同格式:type/value/label/isForm/required/fieldType/defaultValue/attrs),
|
||||
// 解析时剔除包装字段取 code/message 的字段路径(复用 normalizeSchemaPath 归一 attrs/数组段),
|
||||
// 经 GetByPath 从响应体提取。成功判定:
|
||||
// - code 提取值为空(nil/空串/数字零)→ 成功
|
||||
// - code 节点配置了 defaultValue 且提取值等于它 → 成功
|
||||
// - 否则 → 错误(返回 code + message)
|
||||
//
|
||||
// 未配置 ErrorMessageMapping → 不识别错误,一律返回成功(纯配置驱动)。
|
||||
// 解析失败返回 err,由调用方决定重试/终态。
|
||||
func parseModelError(body []byte) (code, msg string, err error) {
|
||||
errMsg := new(dto.ModelErrorResp)
|
||||
if err = gconv.Struct(body, errMsg); err != nil {
|
||||
func parseModelError(body []byte, mapping map[string]any) (code, msg string, err error) {
|
||||
if len(mapping) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
var respObj map[string]any
|
||||
if err = json.Unmarshal(body, &respObj); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !g.IsEmpty(errMsg.Error.Code) {
|
||||
return errMsg.Error.Code, errMsg.Error.Message, nil
|
||||
codePath, msgPath, hasCodeDefault, codeDefault := collectErrorMapping(mapping)
|
||||
codeVal := modelUtils.GetByPathValue(respObj, codePath)
|
||||
msgVal := modelUtils.GetByPathValue(respObj, msgPath)
|
||||
if codePath != "" {
|
||||
if isEmptyErrorCode(codeVal) {
|
||||
return "", "", nil
|
||||
}
|
||||
if hasCodeDefault && sameErrorValue(codeVal, codeDefault) {
|
||||
return "", "", nil
|
||||
}
|
||||
return gconv.String(codeVal), gconv.String(msgVal), nil
|
||||
}
|
||||
flat := new(dto.ModelError1Resp)
|
||||
if err = gconv.Struct(body, flat); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !g.IsEmpty(flat.Code) && flat.Code != 20000000 {
|
||||
return gconv.String(flat.Code), flat.Message, nil
|
||||
// 未配置 code 路径:以 message 是否为空判定错误
|
||||
if !isEmptyErrorCode(msgVal) {
|
||||
return "", gconv.String(msgVal), nil
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// collectErrorMapping 遍历 ErrorMessageMapping schema 树,提取 code/message 的字段路径与 code 节点的 defaultValue。
|
||||
// 支持 schema 节点(含 type/attrs 等包装)与纯字符串路径两种形态;数组段经 normalizeSchemaPath 归一到 [*]。
|
||||
// 返回路径为已归一字段路径;未配置返回空串。
|
||||
func collectErrorMapping(mapping map[string]any) (codePath, msgPath string, hasCodeDefault bool, codeDefault any) {
|
||||
var walk func(node map[string]any, prefix string)
|
||||
walk = func(node map[string]any, prefix string) {
|
||||
for key, val := range node {
|
||||
if isErrorMetaKey(key) {
|
||||
continue
|
||||
}
|
||||
m, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
// 纯值形态:字段值直接是字段路径字符串
|
||||
if s, ok := val.(string); ok {
|
||||
path := normalizeSchemaPath(joinErrorFieldPath(prefix, s))
|
||||
if key == "code" && codePath == "" {
|
||||
codePath = path
|
||||
} else if key == "message" && msgPath == "" {
|
||||
msgPath = path
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
nodeType, _ := m["type"].(string)
|
||||
hasDef := false
|
||||
var def any
|
||||
if d, has := m["defaultValue"]; has && d != nil {
|
||||
hasDef, def = true, d
|
||||
}
|
||||
switch {
|
||||
case nodeType == "object":
|
||||
if attrs, ok := m["attrs"].(map[string]any); ok {
|
||||
walk(attrs, joinErrorFieldPath(prefix, key))
|
||||
} else if attrs, ok := m["attrs"].([]any); ok && len(attrs) > 0 {
|
||||
if elm, ok := attrs[0].(map[string]any); ok {
|
||||
walkErrorArrayElement(elm, joinErrorArrayPath(prefix, key), walk)
|
||||
}
|
||||
} else {
|
||||
walk(m, joinErrorFieldPath(prefix, key))
|
||||
}
|
||||
case nodeType == "array":
|
||||
walkErrorArrayContainer(m, joinErrorArrayPath(prefix, key), walk)
|
||||
case nodeType == "":
|
||||
// 无 type 键:纯容器(字段直接作为键),递归下钻
|
||||
walk(m, joinErrorFieldPath(prefix, key))
|
||||
default:
|
||||
// 标量叶子字段
|
||||
path := normalizeSchemaPath(joinErrorFieldPath(prefix, key))
|
||||
if key == "code" && codePath == "" {
|
||||
codePath, hasCodeDefault, codeDefault = path, hasDef, def
|
||||
} else if key == "message" && msgPath == "" {
|
||||
msgPath = path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(mapping, "")
|
||||
return
|
||||
}
|
||||
|
||||
// walkErrorArrayContainer 数组节点:子字段容器依次尝试 attrs([]any)/enumValues/value([]any)
|
||||
func walkErrorArrayContainer(node map[string]any, prefix string, walk func(map[string]any, string)) {
|
||||
for _, container := range []string{"attrs", "enumValues", "value"} {
|
||||
items, ok := node[container].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
if elm, ok := items[0].(map[string]any); ok {
|
||||
walkErrorArrayElement(elm, prefix, walk)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// walkErrorArrayElement 数组元素:字段在其 attrs 下(对象元素)或直接作为键(纯元素)
|
||||
func walkErrorArrayElement(elm map[string]any, prefix string, walk func(map[string]any, string)) {
|
||||
if attrs, ok := elm["attrs"].(map[string]any); ok {
|
||||
walk(attrs, prefix)
|
||||
return
|
||||
}
|
||||
walk(elm, prefix)
|
||||
}
|
||||
|
||||
// isErrorMetaKey 判断是否为 schema 节点元数据字段(非业务字段,剔除)
|
||||
func isErrorMetaKey(key string) bool {
|
||||
switch key {
|
||||
case "type", "value", "label", "isForm", "required", "fieldType", "defaultValue", "description":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// joinErrorFieldPath 拼接字段路径(数组段 [0] 由 normalizeSchemaPath 归一为 [*])
|
||||
func joinErrorFieldPath(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
// joinErrorArrayPath 数组字段路径:元素下标 [0] 由 normalizeSchemaPath 归一为 [*]
|
||||
func joinErrorArrayPath(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key + "[0]"
|
||||
}
|
||||
return prefix + "." + key + "[0]"
|
||||
}
|
||||
|
||||
// isEmptyErrorCode 判断错误码是否为空(空=无错误):nil / 空串 / 布尔 false / 数字零值
|
||||
func isEmptyErrorCode(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t == ""
|
||||
case bool:
|
||||
return !t
|
||||
}
|
||||
if isNumericType(v) {
|
||||
return gconv.Float64(v) == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sameErrorValue 判断提取值是否等于配置的 defaultValue(数值/字符串跨类型兼容)
|
||||
func sameErrorValue(a, b any) bool {
|
||||
if isNumericType(a) && isNumericType(b) {
|
||||
return gconv.Float64(a) == gconv.Float64(b)
|
||||
}
|
||||
return gconv.String(a) == gconv.String(b)
|
||||
}
|
||||
|
||||
// isNumericType 判断是否为数值类型
|
||||
func isNumericType(v any) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
switch reflect.TypeOf(v).Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||||
reflect.Float32, reflect.Float64:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
// flatMapping 扁平形态:code 配置 defaultValue=20000000 为成功码
|
||||
func flatMapping() map[string]any {
|
||||
return map[string]any{
|
||||
"code": map[string]any{
|
||||
"type": "number", "value": 0, "label": "", "fieldType": "number", "isForm": false, "required": false,
|
||||
"defaultValue": float64(20000000),
|
||||
},
|
||||
"message": map[string]any{
|
||||
"type": "string", "value": "", "label": "", "fieldType": "string", "isForm": false, "required": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelErrorFlatDefaultValue(t *testing.T) {
|
||||
mapping := flatMapping()
|
||||
// 成功:code == defaultValue
|
||||
code, msg, err := parseModelError([]byte(`{"code":20000000,"message":"ok"}`), mapping)
|
||||
if err != nil || code != "" || msg != "" {
|
||||
t.Fatalf("success(default): code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
// 成功:code 数字零值(视为空)
|
||||
code, msg, err = parseModelError([]byte(`{"code":0}`), mapping)
|
||||
if err != nil || code != "" || msg != "" {
|
||||
t.Fatalf("success(zero): code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
// 错误:code != defaultValue
|
||||
code, msg, err = parseModelError([]byte(`{"code":1234,"message":"boom"}`), mapping)
|
||||
if err != nil || code != "1234" || msg != "boom" {
|
||||
t.Fatalf("error: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelErrorNested(t *testing.T) {
|
||||
mapping := map[string]any{
|
||||
"error": map[string]any{
|
||||
"type": "object",
|
||||
"attrs": map[string]any{
|
||||
"code": map[string]any{"type": "string", "label": "", "value": "", "fieldType": "string", "isForm": false, "required": false},
|
||||
"message": map[string]any{"type": "string", "label": "", "value": "", "fieldType": "string", "isForm": false, "required": false},
|
||||
},
|
||||
"label": "", "isForm": false, "required": false, "fieldType": "string",
|
||||
},
|
||||
}
|
||||
// 错误响应
|
||||
code, msg, err := parseModelError([]byte(`{"error":{"code":"rate_limit_exceeded","message":"Too fast"}}`), mapping)
|
||||
if err != nil || code != "rate_limit_exceeded" || msg != "Too fast" {
|
||||
t.Fatalf("error: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
// 成功响应(无 error 字段 → code 提取为空)
|
||||
code, msg, err = parseModelError([]byte(`{"id":"x","choices":[]}`), mapping)
|
||||
if err != nil || code != "" || msg != "" {
|
||||
t.Fatalf("success: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelErrorArray(t *testing.T) {
|
||||
mapping := map[string]any{
|
||||
"errors": map[string]any{
|
||||
"type": "array",
|
||||
"attrs": []any{map[string]any{
|
||||
"code": map[string]any{"type": "number"},
|
||||
"message": map[string]any{"type": "string"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
code, msg, err := parseModelError([]byte(`{"errors":[{"code":1001,"message":"err-a"}]}`), mapping)
|
||||
if err != nil || code != "1001" || msg != "err-a" {
|
||||
t.Fatalf("array error: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
code, msg, err = parseModelError([]byte(`{"errors":[]}`), mapping)
|
||||
if err != nil || code != "" || msg != "" {
|
||||
t.Fatalf("array success: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelErrorPlainStringMapping(t *testing.T) {
|
||||
// 纯字符串路径形态:字段值直接是字段路径
|
||||
mapping := map[string]any{"code": "error.code", "message": "error.message"}
|
||||
code, msg, err := parseModelError([]byte(`{"error":{"code":100,"message":"nope"}}`), mapping)
|
||||
if err != nil || code != "100" || msg != "nope" {
|
||||
t.Fatalf("plain mapping: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
code, msg, err = parseModelError([]byte(`{"data":"ok"}`), mapping)
|
||||
if err != nil || code != "" || msg != "" {
|
||||
t.Fatalf("plain success: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelErrorNoMapping(t *testing.T) {
|
||||
// 未配置 mapping:不识别错误,一律成功(纯配置驱动)
|
||||
code, msg, err := parseModelError([]byte(`{"error":{"code":"x"}}`), nil)
|
||||
if err != nil || code != "" || msg != "" {
|
||||
t.Fatalf("no mapping: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelErrorBadJSON(t *testing.T) {
|
||||
mapping := map[string]any{"code": map[string]any{"type": "string"}}
|
||||
if _, _, err := parseModelError([]byte(`{invalid`), mapping); err == nil {
|
||||
t.Fatalf("bad json should err")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelErrorOnlyMessage(t *testing.T) {
|
||||
// 只配置 message:以 message 是否为空判定错误
|
||||
mapping := map[string]any{"message": map[string]any{"type": "string"}}
|
||||
code, msg, err := parseModelError([]byte(`{"message":"something went wrong"}`), mapping)
|
||||
if err != nil || code != "" || msg != "something went wrong" {
|
||||
t.Fatalf("only-message error: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
code, msg, err = parseModelError([]byte(`{"data":"ok"}`), mapping)
|
||||
if err != nil || code != "" || msg != "" {
|
||||
t.Fatalf("only-message success: code=%q msg=%q err=%v", code, msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectErrorMapping(t *testing.T) {
|
||||
// 扁平 + defaultValue
|
||||
codePath, msgPath, hasDef, def := collectErrorMapping(flatMapping())
|
||||
if codePath != "code" || msgPath != "message" || !hasDef || def != float64(20000000) {
|
||||
t.Fatalf("flat: code=%q msg=%q hasDef=%v def=%v", codePath, msgPath, hasDef, def)
|
||||
}
|
||||
// 嵌套
|
||||
codePath, msgPath, hasDef, def = collectErrorMapping(map[string]any{
|
||||
"error": map[string]any{"type": "object", "attrs": map[string]any{
|
||||
"code": map[string]any{"type": "string"},
|
||||
"message": map[string]any{"type": "string"},
|
||||
}},
|
||||
})
|
||||
if codePath != "error.code" || msgPath != "error.message" || hasDef {
|
||||
t.Fatalf("nested: code=%q msg=%q hasDef=%v", codePath, msgPath, hasDef)
|
||||
}
|
||||
// 数组 → [*]
|
||||
codePath, _, _, _ = collectErrorMapping(map[string]any{
|
||||
"errors": map[string]any{"type": "array", "attrs": []any{
|
||||
map[string]any{"code": map[string]any{"type": "number"}},
|
||||
}},
|
||||
})
|
||||
if codePath != "errors[*].code" {
|
||||
t.Fatalf("array: code=%q", codePath)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ LOOP:
|
||||
}
|
||||
docMsg := new(dto.ModelCallRes)
|
||||
docMsg.TaskId = id
|
||||
errCode, errMsg, err := parseModelError(modelRespBody)
|
||||
errCode, errMsg, err := parseModelError(modelRespBody, modelInfo.ErrorMessageMapping)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
|
||||
+9
-1
@@ -353,4 +353,12 @@ UPDATE model_gateway_model_manage r SET
|
||||
FROM model_gateway_model_manage s
|
||||
WHERE r.system_model = false AND s.system_model = true
|
||||
AND r.model_name = s.model_name
|
||||
AND r.ref_system_model_id IS NULL;
|
||||
AND r.ref_system_model_id IS NULL;
|
||||
|
||||
-- =========================
|
||||
-- 错误消息映射:model_manage 新增 error_message_mapping(JSONB,schema 树形态,解析模型错误响应用)
|
||||
-- =========================
|
||||
ALTER TABLE model_gateway_model_manage
|
||||
ADD COLUMN IF NOT EXISTS error_message_mapping JSONB DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_manage.error_message_mapping
|
||||
IS '错误消息映射:{code,message} 的 schema 树(type/attrs/value/defaultValue),解析模型错误响应,defaultValue 为成功码';
|
||||
Reference in New Issue
Block a user