fix: clarify ark agent plan model discovery

This commit is contained in:
stupid-h4er
2026-05-27 10:52:59 +08:00
parent fa9a3c1920
commit 3be0df0df6
6 changed files with 121 additions and 17 deletions
+2 -1
View File
@@ -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。本地/内网地址无法被火山服务器拉取。
### 推荐流程
+3 -1
View File
@@ -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。
+27 -11
View File
@@ -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 URLhttps://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)}
+62
View File
@@ -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)
}
}
+6 -1
View File
@@ -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() {
</Button>
</Space.Compact>
</Flex>
<Typography.Text type="secondary"> OpenAI /models </Typography.Text>
<Tabs
activeKey={modelSelectTab}
onChange={(key) => setModelSelectTab(key as ModelSelectTabKey)}
@@ -765,7 +770,7 @@ export default function AdminSettingsPage() {
destroyOnHidden
>
<Flex vertical gap={12}>
<Typography.Text type="secondary"> hi</Typography.Text>
<Typography.Text type="secondary"> hiAgent Plan / Seedance </Typography.Text>
<Input.Search placeholder="搜索模型..." allowClear value={testKeyword} onChange={(event) => setTestKeyword(event.target.value)} />
<Table
rowKey="model"
+21 -3
View File
@@ -126,8 +126,26 @@ export function useEffectiveConfig() {
export function buildApiUrl(baseUrl: string, path: string) {
let normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const arkPlanIndex = normalizedBaseUrl.toLowerCase().indexOf("/api/plan/v3");
if (arkPlanIndex >= 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;
}
}