diff --git a/backend/internal/app/cloud_agent_contract_test.go b/backend/internal/app/cloud_agent_contract_test.go index a8aef5a53..f5edadcf3 100644 --- a/backend/internal/app/cloud_agent_contract_test.go +++ b/backend/internal/app/cloud_agent_contract_test.go @@ -187,6 +187,14 @@ func TestCloudAgentToolsFollowCanvasCapabilityRegistry(t *testing.T) { if got := functions["generate_media"]["properties"].(map[string]any)["mode"].(map[string]any)["enum"]; !reflect.DeepEqual(got, cloudAgentGenerationModeNames()) { t.Fatalf("generation modes differ from implemented adapters: %v", got) } + sizeDescription, _ := functions["generate_media"]["properties"].(map[string]any)["size"].(map[string]any)["description"].(string) + if !strings.Contains(sizeDescription, "imageSize/image.size.parameter") || !strings.Contains(sizeDescription, "allowCustom=true") { + t.Fatalf("generation size schema does not explain model-specific parameters: %q", sizeDescription) + } + splitProperties := functions["image_layer_split"]["properties"].(map[string]any) + if _, ok := splitProperties["size"]; !ok { + t.Fatal("image_layer_split is missing the shared image size field") + } if got := CloudAgentCapabilitySetInfo(); got.Hash != canvasCapabilityRegistry.Hash() || got.Version != capability.SetVersion || !reflect.DeepEqual(got.Nodes, canvasCapabilityRegistry.Types()) { t.Fatalf("capability endpoint info differs from registry: %+v", got) } diff --git a/backend/internal/app/cloud_agent_media_capability_error.go b/backend/internal/app/cloud_agent_media_capability_error.go index de5e1116e..538f30fb8 100644 --- a/backend/internal/app/cloud_agent_media_capability_error.go +++ b/backend/internal/app/cloud_agent_media_capability_error.go @@ -1,56 +1,6 @@ package app -import "strings" - -const cloudAgentMediaModelListHint = "优先使用本回合已有 model_list 结果;只有 mode、referenceNodeIds 或模型选择发生变化时才重新查询;没有匹配项时向用户说明,不重复读取同一目录" - -// 模型能力校验(model_capability_validate.go)拒绝的生成参数属于「模型填错了参数」, -// 不是模型、权限或预算问题。此前它们和其它准入失败一样被包成不可重试的 -// media_admission_failed:工具结果只有一句「工具执行失败」,整轮直接终止, -// 模型既不知道错在哪个参数,也没有机会改正;用户要求重试时它只能原样再错一次。 -// -// 这里把这类错误改报为字段级参数错误:结果里带着具体原因和取值来源, -// 走已有的 fix_arguments 纠正通道(次数仍受 cloudAgentToolAttemptLimit 限制), -// 没有任何任务被提交,也不会产生费用。 -var cloudAgentMediaCapabilityArgumentHints = []struct { - marker string - field string - hint string -}{ - {"图片尺寸不在当前模型支持范围内", "size", "size 必须取 model_list 返回的该模型 options.image.size.values 中的一个值(通常是像素尺寸,例如 1536x2752),不要写 9:16 这样的比例"}, - {"图片尺寸宽高必须是 16 的倍数", "size", "size 必须使用宽x高,并且宽高都必须是 16 的倍数"}, - {"图片尺寸最长边不能超过", "size", "size 的最长边不能超过当前模型允许的上限"}, - {"图片宽高比不能超过", "size", "size 的宽高比必须落在当前模型允许的范围内"}, - {"图片总像素需在", "size", "size 的总像素必须落在当前模型允许的范围内"}, - {"当前分辨率不支持所选图片宽高比", "size", "所选 quality 档位下没有这个宽高比;按 model_list 返回的该模型 options.image.size.presets 选一组匹配的比例与分辨率"}, - {"图片质量不在当前模型支持范围内", "quality", "quality 必须取 model_list 返回的该模型 options.image.quality.values 中的一个值,或省略"}, - {"当前图片模型最多支持", "logicalModelId", cloudAgentMediaModelListHint}, - {"参考图片文件超过当前模型大小限制", "logicalModelId", cloudAgentMediaModelListHint}, - {"当前图片模型不支持蒙版编辑", "logicalModelId", cloudAgentMediaModelListHint}, - {"视频时长不在当前模型支持范围内", "durationSeconds", "durationSeconds 必须落在 model_list 返回的该模型 options.video.duration 给出的范围或固定值内"}, - {"画面比例不在当前模型支持范围内", "size", "视频的 size 必须取 model_list 返回的该模型 options.video.ratios 中的一个比例"}, - {"输出分辨率不在当前模型支持范围内", "quality", "视频的 quality 必须取 model_list 返回的该模型 options.video.resolutions 中的一个值,大小写保持一致"}, - {"参考素材数量超过当前模型限制", "logicalModelId", cloudAgentMediaModelListHint}, - {"当前视频模型至少需要", "logicalModelId", cloudAgentMediaModelListHint}, - {"参考视频文件超过当前模型大小限制", "logicalModelId", cloudAgentMediaModelListHint}, - {"参考音频文件超过当前模型大小限制", "logicalModelId", cloudAgentMediaModelListHint}, - {"参考视频时长超过当前模型限制", "logicalModelId", cloudAgentMediaModelListHint}, - {"参考音频时长超过当前模型限制", "logicalModelId", cloudAgentMediaModelListHint}, - {"火山方舟全模态参考不支持纯音频或文本+音频", "logicalModelId", cloudAgentMediaModelListHint}, - {"当前视频模型不支持该生成模式", "logicalModelId", cloudAgentMediaModelListHint}, -} - -// cloudAgentMediaCapabilityArgumentError 识别能力校验错误并转成可纠正的参数错误; -// 其它错误返回 nil,由调用方按原口径处理。 -func cloudAgentMediaCapabilityArgumentError(err error) error { - if err == nil { - return nil - } - message := err.Error() - for _, item := range cloudAgentMediaCapabilityArgumentHints { - if strings.Contains(message, item.marker) { - return cloudAgentFieldError(item.field, "unsupported_value", item.marker+";"+item.hint+"。未提交生成任务,改正后可直接重新调用") - } - } - return nil -} +// Media capability failures are converted to modelCapabilityArgumentError or +// ModelError at the shared task admission boundary. The Agent consumes those +// stable fields in model_capability_argument.go; no provider error text is +// parsed here. diff --git a/backend/internal/app/cloud_agent_media_capability_error_test.go b/backend/internal/app/cloud_agent_media_capability_error_test.go index 37515cd15..124cadf7b 100644 --- a/backend/internal/app/cloud_agent_media_capability_error_test.go +++ b/backend/internal/app/cloud_agent_media_capability_error_test.go @@ -6,10 +6,8 @@ import ( "testing" ) -// 复现:generate_media 把 size 写成 "9:16" 这类比例值,而模型只接受像素尺寸; -// 修复前结果只有「工具执行失败」,整轮终止,重试时模型原样再错。 func TestCloudAgentMediaCapabilityErrorBecomesCorrectableArgumentError(t *testing.T) { - wrapped := cloudAgentWrapMediaAdmissionError(BadAuthRequest("图片尺寸不在当前模型支持范围内")) + wrapped := cloudAgentWrapMediaAdmissionError(newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("自定义图片尺寸请使用宽x高,例如 3840x1920"))) var argumentErr *cloudAgentArgumentError if !errors.As(wrapped, &argumentErr) { @@ -23,27 +21,33 @@ func TestCloudAgentMediaCapabilityErrorBecomesCorrectableArgumentError(t *testin if class != cloudAgentToolErrorSchemaError || !retryable || action != "fix_arguments" { t.Fatalf("class=%s retryable=%v action=%s", class, retryable, action) } - text := cloudAgentSafeToolError(wrapped) - if !strings.Contains(text, "图片尺寸不在当前模型支持范围内") || !strings.Contains(text, "model_list") { + if text := cloudAgentSafeToolError(wrapped); !strings.Contains(text, "自定义图片尺寸请使用宽x高") { t.Fatalf("model does not see the reason: %q", text) } } -func TestCloudAgentMediaCapabilityErrorCoversDurationRatioResolutionAndModelChoice(t *testing.T) { - for message, field := range map[string]string{ - "视频时长不在当前模型支持范围内": "durationSeconds", - "画面比例不在当前模型支持范围内": "size", - "输出分辨率不在当前模型支持范围内": "quality", - "图片质量不在当前模型支持范围内": "quality", - "当前视频模型不支持该生成模式": "logicalModelId", - "参考素材数量超过当前模型限制": "logicalModelId", - "参考视频时长超过当前模型限制": "logicalModelId", - "图片尺寸宽高必须是 16 的倍数": "size", +func TestCloudAgentMediaCapabilityErrorsUseStableFields(t *testing.T) { + for _, test := range []struct { + field string + text string + }{ + {field: "size", text: "所选模型不支持当前请求"}, + {field: "quality", text: "图片质量不在当前模型支持范围内"}, + {field: "count", text: "输出数量不支持"}, } { - var fieldErr *cloudAgentFieldArgumentError - if !errors.As(cloudAgentWrapMediaAdmissionError(BadAuthRequest(message)), &fieldErr) || fieldErr.Field != field { - t.Fatalf("%s → %#v, want field %s", message, fieldErr, field) - } + t.Run(test.field, func(t *testing.T) { + wrapped := cloudAgentWrapMediaAdmissionError(newModelCapabilityArgumentError(test.field, "unsupported_value", BadAuthRequest(test.text))) + var fieldErr *cloudAgentFieldArgumentError + if !errors.As(wrapped, &fieldErr) || fieldErr.Field != test.field || fieldErr.Issue != "unsupported_value" { + t.Fatalf("field error = %#v", fieldErr) + } + }) + } + match := CapabilityMatch{Issues: []CapabilityIssue{{Kind: "option", Name: "size", Reason: "参数 画面尺寸超出支持范围"}}} + wrapped := cloudAgentWrapMediaAdmissionError(modelCapabilityMismatchError(match)) + var fieldErr *cloudAgentFieldArgumentError + if !errors.As(wrapped, &fieldErr) || fieldErr.Field != "size" { + t.Fatalf("route mismatch field = %#v", fieldErr) } } @@ -58,7 +62,6 @@ func TestCloudAgentMediaAdmissionFailureMessageKeepsReason(t *testing.T) { } } -// 模型、权限、预算等其它准入失败保持原口径:不可自动重试,交给用户。 func TestCloudAgentMediaAdmissionErrorStaysTerminalForOtherFailures(t *testing.T) { wrapped := cloudAgentWrapMediaAdmissionError(BadAuthRequest("已超过本轮视频时长预算")) var admissionErr *cloudAgentMediaAdmissionError diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index badf9a753..d04a59d6c 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -26,7 +26,8 @@ func cloudAgentCanonicalFor(system string, history []providerTextMessage, prompt const ( cloudAgentPromptCacheSchemaVersion = "cloud-agent-prompt-cache/v2" - cloudAgentToolSchemaVersion = "cloud-agent-tools/v4" + cloudAgentToolSchemaVersion = "cloud-agent-tools/v5" + cloudAgentMediaSizeDescription = "按本回合 model_list 返回的 imageSize/image.size.parameter 传值:parameter=size 时使用支持值,只有 allowCustom=true 才能使用宽x高;parameter=aspect_ratio 时使用 W:H;不要把冒号或比例值传给像素尺寸模型" ) // cloudAgentPromptCacheIdentity deliberately excludes the canvas payload and its @@ -209,7 +210,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 { add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;所有权限模式都会先创建草稿并进入界面独立审批,用户批准后才提交生成任务。", map[string]any{ "prompt": str("需要拆分的对象与透明背景要求"), "logicalModelId": str("selection.logicalModelId"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"), - "quality": str("模型支持的质量档位"), "snapshotHash": str("最近画布读取返回的 mediaSnapshotHash,可省略"), "nodeId": str("新的结果节点ID"), "title": str("结果节点名称"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("源图片节点ID")}, + "size": str(cloudAgentMediaSizeDescription), "quality": str("模型支持的质量档位"), "snapshotHash": str("最近画布读取返回的 mediaSnapshotHash,可省略"), "nodeId": str("新的结果节点ID"), "title": str("结果节点名称"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("源图片节点ID")}, }, "prompt", "nodeId", "title", "referenceNodeIds") cloudAgentRequireExplicitMediaModelSelection(tools[len(tools)-1]) add("model_list", "读取当前生效的生成模型目录、能力与价格档。生成前传 mode 和本次实际 referenceNodeIds,服务端按真实素材类型、数量和生成操作筛选匹配模型;空列表表示无匹配项,不得退回不匹配模型。相同条件直接复用,变化后再查。复制 selection 到 generate_media,不猜ID或混用模型选择;再按返回的能力配置核对时长、画幅、音频和价格。", map[string]any{"mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("本次实际使用的画布媒体参考节点ID;文生媒体传空数组")}}) @@ -287,7 +288,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma add("generate_media", "提交媒体生成:先准备草稿和引用;所有权限模式均经模型/能力/价格/预算/资源校验和界面独立审批,用户批准后才提交。编辑节点或连线用 canvas_apply_ops;先读画布与模型。已有任务或产物不可覆盖,状态用 generation/task_get。sourceNodeId 为文本,referenceNodeIds 为媒体;角色卡作为参考同时采用设定与三视图,仅取设定则用 sourceNodeId,不要重复指定。临时引用只接受标注工具ID,不接受URL。参数取本回合 model_list;失败先改参数,勿重提或重读。失败须告知用户,重试需明确要求并重新审批。", map[string]any{ "mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "prompt": str("完整生成提示词;引用素材时在对应描述中使用 @图片1、@视频1、@音频1,各类型按 referenceNodeIds 中出现顺序独立编号,文本来源不占媒体编号。服务端会为遗漏的已选素材补齐引用标签,不推断素材用途"), "logicalModelId": str("selection.logicalModelId;与channelId/channelModelKey互斥"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"), - "durationSeconds": map[string]any{"type": "integer", "minimum": 0}, "size": str("模型支持的画幅,例如9:16"), "quality": str("目录支持的分辨率或质量"), "videoGenerateAudio": map[string]any{"type": "boolean", "description": "是否生成音频,仅视频可用"}, + "durationSeconds": map[string]any{"type": "integer", "minimum": 0}, "size": str(cloudAgentMediaSizeDescription), "quality": str("按本回合 model_list 返回的 image.quality 或 video.resolutions 取值;视频输出分辨率保持目录返回的大小写"), "videoGenerateAudio": map[string]any{"type": "boolean", "description": "是否生成音频,仅视频可用"}, "videoStartFrameNodeId": str("视频首帧图片ID,须在 referenceNodeIds 中"), "videoEndFrameNodeId": str("视频尾帧图片ID,须在 referenceNodeIds 中"), "snapshotHash": str("可省略:省略时用当前画布内容快照"), "nodeId": str("可续用的未提交媒体草稿ID;无草稿时才使用新唯一ID"), "title": str("媒体节点名称"), "sourceNodeId": str("仅文本/镜头提示词节点ID;不要填媒体节点"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("画布媒体参考节点ID,按引用顺序")}, "referenceTransientIds": map[string]any{"type": "array", "maxItems": 4, "items": str("由 image_annotation_render 返回的临时参考图ID")}, diff --git a/backend/internal/app/model_capability_argument.go b/backend/internal/app/model_capability_argument.go new file mode 100644 index 000000000..a57f157fa --- /dev/null +++ b/backend/internal/app/model_capability_argument.go @@ -0,0 +1,67 @@ +package app + +import "errors" + +// modelCapabilityArgumentError preserves the machine-readable field that caused +// a capability rejection while keeping the normal 400 AppError projection for +// non-Agent task callers. +type modelCapabilityArgumentError struct { + *AppError + Field string + Issue string +} + +func (e *modelCapabilityArgumentError) Unwrap() error { + if e == nil { + return nil + } + return e.AppError +} + +func newModelCapabilityArgumentError(field, issue string, cause error) error { + if cause == nil { + cause = BadAuthRequest("模型能力参数无效") + } + return &modelCapabilityArgumentError{AppError: BadAuthRequest(cause.Error()), Field: field, Issue: issue} +} + +func modelCapabilityMismatchError(match CapabilityMatch) error { + details := map[string]any{"capabilityIssues": match.Issues} + for _, issue := range match.Issues { + if issue.Kind != "option" { + continue + } + field := issue.Name + if field == "vquality" { + field = "quality" + } + details["field"] = field + details["issue"] = "unsupported_value" + details["message"] = issue.Reason + break + } + return NewModelError(ErrCodeModelCapabilityNotSupported, "所选模型不支持当前请求").WithDetails(details) +} + +func cloudAgentMediaCapabilityArgumentError(err error) error { + if err == nil { + return nil + } + var argumentErr *modelCapabilityArgumentError + if errors.As(err, &argumentErr) && argumentErr != nil && argumentErr.Field != "" { + return cloudAgentFieldError(argumentErr.Field, argumentErr.Issue, argumentErr.Error()) + } + var modelErr *ModelError + if errors.As(err, &modelErr) && modelErr != nil { + field, _ := modelErr.Details["field"].(string) + issue, _ := modelErr.Details["issue"].(string) + if field != "" && issue != "" { + message, _ := modelErr.Details["message"].(string) + if message == "" { + message = modelErr.Error() + } + return cloudAgentFieldError(field, issue, message+";请按本回合 model_list 返回的能力参数修正后重试") + } + } + return nil +} diff --git a/backend/internal/app/model_capability_validate.go b/backend/internal/app/model_capability_validate.go index 12fa6851f..5eea1cc06 100644 --- a/backend/internal/app/model_capability_validate.go +++ b/backend/internal/app/model_capability_validate.go @@ -329,44 +329,44 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp return err } if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios { - return BadAuthRequest("参考素材数量超过当前模型限制") + return newModelCapabilityArgumentError("referenceNodeIds", "item_count", BadAuthRequest("参考素材数量超过当前模型限制")) } if model.IsVolcengineArkVideoProtocol(model.ChannelInterfaceType(input.Config.InterfaceType)) && len(input.ReferenceAudios) > 0 && len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 { - return BadAuthRequest("火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频") + return newModelCapabilityArgumentError("referenceNodeIds", "invalid_combination", BadAuthRequest("火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频")) } if len(input.ReferenceImages) < profile.References.MinImages { - return BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages)) + return newModelCapabilityArgumentError("referenceNodeIds", "item_count", BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages))) } for _, media := range input.ReferenceImages { if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { - return BadAuthRequest("参考图片文件超过当前模型大小限制") + return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考图片文件超过当前模型大小限制")) } } for _, media := range input.ReferenceVideos { if profile.References.MaxVideoBytes > 0 && media.Bytes > profile.References.MaxVideoBytes { - return BadAuthRequest("参考视频文件超过当前模型大小限制") + return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考视频文件超过当前模型大小限制")) } if profile.References.MaxVideoDuration > 0 && media.DurationMs > int64(profile.References.MaxVideoDuration)*1000 { - return BadAuthRequest("参考视频时长超过当前模型限制") + return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考视频时长超过当前模型限制")) } } for _, media := range input.ReferenceAudios { if profile.References.MaxAudioBytes > 0 && media.Bytes > profile.References.MaxAudioBytes { - return BadAuthRequest("参考音频文件超过当前模型大小限制") + return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考音频文件超过当前模型大小限制")) } if profile.References.MaxAudioDuration > 0 && media.DurationMs > int64(profile.References.MaxAudioDuration)*1000 { - return BadAuthRequest("参考音频时长超过当前模型限制") + return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考音频时长超过当前模型限制")) } } seconds, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds)) if err != nil || !videoDurationAllowed(profile.Duration, seconds) { - return BadAuthRequest("视频时长不在当前模型支持范围内") + return newModelCapabilityArgumentError("durationSeconds", "unsupported_value", BadAuthRequest("视频时长不在当前模型支持范围内")) } if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) { - return BadAuthRequest("画面比例不在当前模型支持范围内") + return newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("画面比例不在当前模型支持范围内")) } if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" { - return BadAuthRequest("输出分辨率不在当前模型支持范围内") + return newModelCapabilityArgumentError("quality", "unsupported_value", BadAuthRequest("输出分辨率不在当前模型支持范围内")) } operation := metadataString(input.Metadata, "videoEditOperation") if operation == "" { @@ -377,7 +377,7 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp } } if !containsCapabilityString(profile.Operations, operation) { - return BadAuthRequest("当前视频模型不支持该生成模式") + return newModelCapabilityArgumentError("logicalModelId", "unsupported_value", BadAuthRequest("当前视频模型不支持该生成模式")) } return nil } @@ -400,47 +400,46 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp } } if len(input.ReferenceImages) > profile.References.MaxImages { - return BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages)) + return newModelCapabilityArgumentError("referenceNodeIds", "item_count", BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages))) } for _, media := range input.ReferenceImages { if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { - return BadAuthRequest("参考图片文件超过当前模型大小限制") + return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考图片文件超过当前模型大小限制")) } } if input.Mask != nil && !profile.References.MaskSupported { - return BadAuthRequest("当前图片模型不支持蒙版编辑") + return newModelCapabilityArgumentError("logicalModelId", "unsupported_value", BadAuthRequest("当前图片模型不支持蒙版编辑")) } quality := strings.TrimSpace(input.Config.Quality) resolution := strings.TrimSpace(input.Config.ImageResolution) if profile.Resolution.Supported && resolution != "" && !strings.EqualFold(resolution, "auto") && !strings.EqualFold(resolution, "any") && !containsCapabilityString(profile.Resolution.Values, resolution) { - return BadAuthRequest("图片分辨率不在当前模型支持范围内") + return newModelCapabilityArgumentError("imageResolution", "unsupported_value", BadAuthRequest("图片分辨率不在当前模型支持范围内")) } selectionTier := quality if profile.Resolution.Supported { selectionTier = resolution } // 比例型线路会在任务输入边界把画布比例换算成 preset 像素值(OpenAI Images 只收 - // WIDTHxHEIGHT)。换算结果必须继续通过同一套尺寸与分辨率校验:先把已声明的 preset - // 像素值还原成对应比例,否则执行前的 provider 预检会把刚换算好的值判成不支持的尺寸。 + // WIDTHxHEIGHT)。换算结果必须继续通过同一套尺寸与分辨率校验。 resolvedRatio := imageSizeRatioForDeclaredPreset(profile, input.Config.Size, qualityForImageValidation(selectionTier, profile.Resolution.Default)) if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" && !containsCapabilityString(profile.Size.Values, input.Config.Size) && resolvedRatio == "" { - return BadAuthRequest("图片尺寸不在当前模型支持范围内") + return newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("图片尺寸不在当前模型支持范围内")) } if profile.Parameters == nil && profile.Size.Parameter == "size" && profile.Size.AllowCustom && strings.HasPrefix(modelName, "gpt-image-2") && !containsCapabilityString(profile.Size.Values, input.Config.Size) { if err := validateGPTImage2CustomSize(input.Config.Size); err != nil { - return BadAuthRequest(err.Error()) + return newModelCapabilityArgumentError("size", "unsupported_value", err) } } if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) { - return BadAuthRequest("图片质量不在当前模型支持范围内") + return newModelCapabilityArgumentError("quality", "unsupported_value", BadAuthRequest("图片质量不在当前模型支持范围内")) } if err := validateImagePresetSelection(profile, qualityForImageValidation(selectionTier, firstNonEmpty(profile.Resolution.Default, profile.Quality.Default)), firstNonEmpty(resolvedRatio, input.Config.Size, profile.Size.Default)); err != nil { return err } count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)) if err == nil && count > profile.MaxOutputs { - return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs)) + return newModelCapabilityArgumentError("count", "unsupported_value", BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs))) } return nil } @@ -488,7 +487,7 @@ func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio return nil } } - return BadAuthRequest("当前分辨率不支持所选图片宽高比") + return newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("当前分辨率不支持所选图片宽高比")) } // imageSizeRatioForDeclaredPreset 把已声明的 preset 像素值还原成它对应的比例。比例型线路 diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index 22c88877c..6583bfe68 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -207,7 +207,7 @@ func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequest } } if match := MatchCapability(cached.ProductSpec, intent); !match.Matched { - return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";")) + return nil, modelCapabilityMismatchError(match) } eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel") if len(eligible) == 0 { diff --git a/backend/internal/app/model_router_capability.go b/backend/internal/app/model_router_capability.go index cc4ae1d2a..3c3d61186 100644 --- a/backend/internal/app/model_router_capability.go +++ b/backend/internal/app/model_router_capability.go @@ -151,9 +151,16 @@ func normalizeModelRequestOption(name string, value any) any { } } +type CapabilityIssue struct { + Kind string `json:"kind"` + Name string `json:"name"` + Reason string `json:"reason"` +} + type CapabilityMatch struct { - Matched bool `json:"matched"` - Reasons []string `json:"reasons,omitempty"` + Matched bool `json:"matched"` + Reasons []string `json:"reasons,omitempty"` + Issues []CapabilityIssue `json:"issues,omitempty"` } type cachedLogicalModel struct { @@ -292,6 +299,11 @@ func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, err func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch { reasons := make([]string, 0) + issues := make([]CapabilityIssue, 0) + addIssue := func(kind, name, reason string) { + reasons = append(reasons, reason) + issues = append(issues, CapabilityIssue{Kind: kind, Name: name, Reason: reason}) + } if spec.Capability == "image" && spec.ImageParameters != nil { values := make(map[string]any, len(intent.Options)) for name, value := range intent.Options { @@ -313,51 +325,51 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM } } if _, err := resolveImageParameterValues(parameters, values); err != nil { - reasons = append(reasons, err.Error()) + addIssue("image_parameter", "imageParameters", err.Error()) } } if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) { - reasons = append(reasons, "能力类型不匹配") + addIssue("capability", "capability", "能力类型不匹配") } if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) { - reasons = append(reasons, "不支持操作 "+intent.Operation) + addIssue("operation", "operation", "不支持操作 "+intent.Operation) } for inputType, count := range intent.Inputs { if count < 0 { - reasons = append(reasons, "输入数量不能小于 0") + addIssue("input", inputType, "输入数量不能小于 0") continue } constraint, declared := spec.Inputs[inputType] if !declared { if count > 0 { - reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") + addIssue("input", inputType, "不支持 "+capabilityInputLabel(inputType)+"输入") } continue } if count < constraint.Min || count > constraint.Max { - reasons = append(reasons, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max)) + addIssue("input", inputType, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max)) } } for inputType, constraint := range spec.Inputs { if intent.Inputs[inputType] < constraint.Min { - reasons = append(reasons, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType))) + addIssue("input", inputType, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType))) } } for name, value := range intent.Options { - canonicalName := canonicalModelOptionName(spec.Capability, name) - if inactiveCapabilityOptionValue(canonicalName, value) { + canonical := canonicalModelOptionName(spec.Capability, name) + if inactiveCapabilityOptionValue(canonical, value) { continue } - constraint, declared := spec.Options[canonicalName] + constraint, declared := spec.Options[canonical] if !declared { - reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name)) + addIssue("option", canonical, "不支持参数 "+capabilityOptionLabel(name)) continue } if !matchOptionConstraint(name, constraint, value) { - reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围") + addIssue("option", canonical, "参数 "+capabilityOptionLabel(name)+"超出支持范围") } } - return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons} + return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons, Issues: issues} } // Image size intent and provider encoding share explicit preset mappings. diff --git a/backend/internal/app/task_creation.go b/backend/internal/app/task_creation.go index e4e8fff2f..509cced75 100644 --- a/backend/internal/app/task_creation.go +++ b/backend/internal/app/task_creation.go @@ -578,7 +578,7 @@ func (s *Service) resolveSystemChannelModelSelection(input map[string]any, taskT return input, InvalidModelSelection("指定的模型能力配置无效,请联系管理员") } if match := MatchCapability(*capabilitySpec, intent); !match.Matched { - return input, ModelCapabilityNotSupported("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";")) + return input, modelCapabilityMismatchError(match) } }