fix(agent): 媒体准入参数结构化纠错
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")},
|
||||
|
||||
67
backend/internal/app/model_capability_argument.go
Normal file
67
backend/internal/app/model_capability_argument.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 像素值还原成它对应的比例。比例型线路
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user