fix: clarify ark agent plan model discovery
This commit is contained in:
@@ -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
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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">普通文本模型会发送一条 hi;Agent Plan / Seedance 视频模型只做配置格式检查,不会发起视频生成,也不代表模型权限已验证。</Typography.Text>
|
||||
<Input.Search placeholder="搜索模型..." allowClear value={testKeyword} onChange={(event) => setTestKeyword(event.target.value)} />
|
||||
<Table
|
||||
rowKey="model"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user