From 3be0df0df67adb1c5da5d8b5c33992fe1ecd4d13 Mon Sep 17 00:00:00 2001 From: stupid-h4er Date: Wed, 27 May 2026 10:52:59 +0800 Subject: [PATCH] fix: clarify ark agent plan model discovery --- docs/canvas-node-manual.md | 3 +- docs/features.md | 4 +- service/settings.go | 38 +++++++++---- service/settings_test.go | 62 +++++++++++++++++++++ web/src/app/(admin)/admin/settings/page.tsx | 7 ++- web/src/stores/use-config-store.ts | 24 +++++++- 6 files changed, 121 insertions(+), 17 deletions(-) create mode 100644 service/settings_test.go diff --git a/docs/canvas-node-manual.md b/docs/canvas-node-manual.md index 48db9b8..4849bac 100644 --- a/docs/canvas-node-manual.md +++ b/docs/canvas-node-manual.md @@ -20,7 +20,7 @@ - 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。 - 输入内容可以手写,也可以从提示词库选择。 - 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。 -- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。 +- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。 ### 用文本节点生成图片 @@ -48,6 +48,7 @@ - 生成配置节点的视频模式会读取上游文本作为 prompt,读取上游图片作为参考图,读取上游视频作为参考视频,并在输入预览里显示参考视频。 - 视频生成接口支持 OpenAI 风格的 `POST /v1/videos`、`GET /v1/videos/{id}` 和 `GET /v1/videos/{id}/content`。 - 使用火山方舟 Agent Plan / Seedance 2.0 时,Base URL 配置为 `https://ark.cn-beijing.volces.com/api/plan/v3`,模型名使用 Seedance 2.0 对应模型;系统会改用 `POST /contents/generations/tasks` 创建异步任务,并轮询 `GET /contents/generations/tasks/{id}`。 +- Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,后台不会伪造模型列表;请手动填写 `doubao-seedance-2.0` 或文档列出的其他可用模型。 - Seedance 参考视频必须是公网可访问 URL,或由本项目后端在配置 `PUBLIC_BASE_URL` 后上传并暴露的参考素材 URL。本地/内网地址无法被火山服务器拉取。 ### 推荐流程 diff --git a/docs/features.md b/docs/features.md index 344dd90..5b7a1ca 100644 --- a/docs/features.md +++ b/docs/features.md @@ -62,7 +62,7 @@ OpenAI 兼容图像和文本能力继续复用现有接口: - `/v1/images/generations`:文生图。 - `/v1/images/edits`:图生图/参考图编辑。 - `/v1/chat/completions`:文本问答和带图问答。 -- `/v1/models`:读取模型列表。 +- `/v1/models`:读取模型列表;火山方舟 Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,需要手动填写模型名。 视频能力支持两类接口: @@ -71,6 +71,8 @@ OpenAI 兼容图像和文本能力继续复用现有接口: Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不会再追加 `/v1`。因此 cpa 反代或火山方舟 Agent Plan 可以继续通过现有 Base URL + API Key + Model 方式配置,不需要新增火山生图 Provider。 +后台“拉取模型列表”会尝试真实请求 OpenAI `/models`,不会为 Agent Plan 伪造模型结果。如果火山方舟 Agent Plan 返回 404,请手动增加 `doubao-seedance-2.0` 或文档列出的其他模型名。 + 可配置项: - Base URL。 diff --git a/service/settings.go b/service/settings.go index e84e5c1..4a943ab 100644 --- a/service/settings.go +++ b/service/settings.go @@ -7,6 +7,7 @@ import ( "io" "math/rand" "net/http" + "net/url" "sort" "strings" @@ -44,9 +45,6 @@ func AdminChannelModels(index *int, channel model.ModelChannel) ([]string, error if err != nil { return nil, err } - if isArkAgentPlanChannel(resolved) { - return []string{"doubao-seedance-2.0"}, nil - } return fetchAdminChannelModels(resolved) } @@ -186,7 +184,8 @@ func SelectModelChannel(modelName string) (model.ModelChannel, error) { func BuildModelChannelURL(channel model.ModelChannel, path string) string { baseURL := normalizeModelChannelBaseURL(channel.BaseURL) - if !strings.HasSuffix(baseURL, "/v1") && !strings.HasSuffix(baseURL, "/api/v3") && !strings.HasSuffix(baseURL, "/api/plan/v3") { + lowerBaseURL := strings.ToLower(baseURL) + if !strings.HasSuffix(lowerBaseURL, "/v1") && !strings.HasSuffix(lowerBaseURL, "/api/v3") && !strings.HasSuffix(lowerBaseURL, "/api/plan/v3") { baseURL += "/v1" } return baseURL + path @@ -194,9 +193,20 @@ func BuildModelChannelURL(channel model.ModelChannel, path string) string { func normalizeModelChannelBaseURL(baseURL string) string { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - lowerBaseURL := strings.ToLower(baseURL) - if index := strings.Index(lowerBaseURL, "/api/plan/v3"); index >= 0 { - return baseURL[:index+len("/api/plan/v3")] + parsed, err := url.Parse(baseURL) + if err == nil && parsed.Scheme != "" && parsed.Host != "" { + path := strings.TrimRight(parsed.Path, "/") + lowerPath := strings.ToLower(path) + if index := strings.Index(lowerPath, "/api/plan/v3"); index >= 0 { + end := index + len("/api/plan/v3") + if len(lowerPath) == end || lowerPath[end] == '/' { + parsed.Path = path[:end] + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.Fragment = "" + return strings.TrimRight(parsed.String(), "/") + } + } } return baseURL } @@ -271,6 +281,9 @@ func fetchAdminChannelModels(channel model.ModelChannel) ([]string, error) { defer response.Body.Close() body, _ := io.ReadAll(response.Body) if response.StatusCode >= http.StatusBadRequest { + if response.StatusCode == http.StatusNotFound && isArkAgentPlanChannel(channel) { + return nil, safeMessageError{message: "火山方舟 Agent Plan 未提供 OpenAI /models 模型列表接口,请手动填写模型名称,例如 doubao-seedance-2.0。"} + } return nil, readAdminChannelError(body, response.StatusCode, "读取模型失败") } var payload struct { @@ -340,9 +353,9 @@ func testArkSeedanceChannelModel(channel model.ModelChannel, modelName string) ( return "", safeMessageError{message: "缺少 API Key"} } if !isArkAgentPlanChannel(channel) { - return "", safeMessageError{message: "Seedance 2.0 请使用火山方舟 Agent Plan Base URL:https://ark.cn-beijing.volces.com/api/plan/v3"} + return "Seedance 视频模型不会发送 /chat/completions 文本测试。已检查 Base URL、API Key 和模型名非空;未调用视频生成接口,因此未验证套餐额度或模型权限。", nil } - return "Agent Plan / Seedance 视频模型配置格式已通过。后台测试不会调用视频生成接口,请在画布中使用视频生成验证模型权限。", nil + return "Agent Plan / Seedance 视频模型配置格式已通过。后台测试不会调用视频生成接口,因此未验证 API Key、套餐额度或模型权限;请在画布中使用视频生成验证。", nil } func readAdminChannelError(body []byte, statusCode int, fallback string) error { @@ -360,8 +373,11 @@ func readAdminChannelError(body []byte, statusCode int, fallback string) error { return safeMessageError{message: payload.Msg} } } - if statusCode == http.StatusUnauthorized { - return safeMessageError{message: "上游接口认证失败(401),请检查 API Key"} + if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden { + return safeMessageError{message: fmt.Sprintf("上游接口鉴权失败(%d),请检查 API Key、套餐权限或模型权限", statusCode)} + } + if statusCode == http.StatusTooManyRequests { + return safeMessageError{message: "上游接口限流或额度不足(429),请稍后重试或检查额度"} } if statusCode > 0 { return safeMessageError{message: fmt.Sprintf("%s:%d", fallback, statusCode)} diff --git a/service/settings_test.go b/service/settings_test.go new file mode 100644 index 0000000..ea92d83 --- /dev/null +++ b/service/settings_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/basketikun/infinite-canvas/model" +) + +func TestFetchAdminChannelModelsParsesOpenAIModels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"z-model"},{"id":"a-model"},{"id":""}]}`)) + })) + defer server.Close() + + models, err := fetchAdminChannelModels(model.ModelChannel{ + BaseURL: server.URL, + APIKey: "test-key", + }) + if err != nil { + t.Fatalf("fetchAdminChannelModels returned error: %v", err) + } + if want := []string{"a-model", "z-model"}; !reflect.DeepEqual(models, want) { + t.Fatalf("models = %#v, want %#v", models, want) + } +} + +func TestFetchAdminChannelModelsReportsArkPlanModelsUnsupported(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/plan/v3/models" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + http.NotFound(w, r) + })) + defer server.Close() + + _, err := fetchAdminChannelModels(model.ModelChannel{ + BaseURL: server.URL + "/api/plan/v3/contents/generations/tasks", + APIKey: "test-key", + }) + if err == nil { + t.Fatal("expected unsupported /models error") + } + if !strings.Contains(err.Error(), "Agent Plan 未提供 OpenAI /models") { + t.Fatalf("error = %q", err.Error()) + } +} + +func TestBuildModelChannelURLNormalizesArkPlanTaskPath(t *testing.T) { + got := BuildModelChannelURL(model.ModelChannel{BaseURL: "https://ark.cn-beijing.volces.com/api/plan/v3/contents/generations/tasks?debug=1"}, "/models") + want := "https://ark.cn-beijing.volces.com/api/plan/v3/models" + if got != want { + t.Fatalf("BuildModelChannelURL = %q, want %q", got, want) + } +} diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 7ca449e..c262680 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -215,6 +215,10 @@ export default function AdminSettingsPage() { const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) }); const current = isModelSelectorOpen ? uniqueModels(modelSelectSelected) : uniqueModels(channelForm.getFieldValue("models") || []); rememberModels(channelModels); + if (!channelModels.length) { + message.warning("上游未返回模型列表,请手动输入模型名称"); + return; + } setModelSelectExisting(current); setModelSelectSource(uniqueModels(channelModels)); setModelSelectSelected(uniqueModels([...current, ...channelModels])); @@ -707,6 +711,7 @@ export default function AdminSettingsPage() { + 如果上游不提供 OpenAI /models 模型列表接口,请在这里手动增加模型名称。 setModelSelectTab(key as ModelSelectTabKey)} @@ -765,7 +770,7 @@ export default function AdminSettingsPage() { destroyOnHidden > - 测试会向选中模型发送一条 hi,用于确认渠道是否有响应。 + 普通文本模型会发送一条 hi;Agent Plan / Seedance 视频模型只做配置格式检查,不会发起视频生成,也不代表模型权限已验证。 setTestKeyword(event.target.value)} /> = 0) normalizedBaseUrl = normalizedBaseUrl.slice(0, arkPlanIndex + "/api/plan/v3".length); - const apiBaseUrl = normalizedBaseUrl.endsWith("/v1") || normalizedBaseUrl.endsWith("/api/v3") || normalizedBaseUrl.endsWith("/api/plan/v3") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`; + normalizedBaseUrl = normalizeArkPlanBaseUrl(normalizedBaseUrl); + const lowerBaseUrl = normalizedBaseUrl.toLowerCase(); + const apiBaseUrl = lowerBaseUrl.endsWith("/v1") || lowerBaseUrl.endsWith("/api/v3") || lowerBaseUrl.endsWith("/api/plan/v3") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`; return `${apiBaseUrl}${path}`; } + +function normalizeArkPlanBaseUrl(baseUrl: string) { + try { + const url = new URL(baseUrl); + const path = url.pathname.replace(/\/+$/, ""); + const lowerPath = path.toLowerCase(); + const arkPlanIndex = lowerPath.indexOf("/api/plan/v3"); + if (arkPlanIndex < 0) return baseUrl; + const end = arkPlanIndex + "/api/plan/v3".length; + if (lowerPath.length !== end && lowerPath[end] !== "/") return baseUrl; + url.pathname = path.slice(0, end); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); + } catch { + return baseUrl; + } +}