feat: add sub2api source compatibility
This commit is contained in:
@@ -40,11 +40,18 @@ cp .env.example .env
|
|||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
本地源码构建运行:
|
本地源码一键运行(适合本机长期调试/兼容版):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
bash scripts/install-local-source.sh install
|
||||||
docker compose -f docker-compose.local.yml up -d --build
|
```
|
||||||
|
|
||||||
|
默认会克隆/更新当前仓库,启动 Go 后端 `8080` 与 Next.js 前端 `3000`,数据保存在 `data/`。常用命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/install-local-source.sh status
|
||||||
|
bash scripts/install-local-source.sh restart
|
||||||
|
bash scripts/install-local-source.sh stop
|
||||||
```
|
```
|
||||||
|
|
||||||
运行后默认端口3000,可访问 `http://localhost:3000`。
|
运行后默认端口3000,可访问 `http://localhost:3000`。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
|
- 管理后台模型渠道新增 Sub2API 兼容模式和图片返回格式配置;选择 Sub2API 后,图片生成/编辑代理会自动携带 `response_format: "url"`,并支持带 `/v1` 的 OpenAI 兼容 Base URL 不重复拼接。需要用真实 Sub2API Key 验证文本、生图、图生图/编辑图三类请求。
|
||||||
|
|
||||||
- 外部软件可通过 URL 查询参数 `baseUrl`/`baseurl` 和 `apiKey`/`apikey` 跳转到前端;读取后会从地址栏移除这些参数,后台允许自定义渠道时会自动切到自定义渠道、填入配置并打开配置弹窗,未允许时会打开配置弹窗并提示无法导入。
|
- 外部软件可通过 URL 查询参数 `baseUrl`/`baseurl` 和 `apiKey`/`apikey` 跳转到前端;读取后会从地址栏移除这些参数,后台允许自定义渠道时会自动切到自定义渠道、填入配置并打开配置弹窗,未允许时会打开配置弹窗并提示无法导入。
|
||||||
- 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1`、`图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。
|
- 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1`、`图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。
|
||||||
- GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。
|
- GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。
|
||||||
|
|||||||
+10
-2
@@ -78,13 +78,17 @@
|
|||||||
"channels": [
|
"channels": [
|
||||||
{
|
{
|
||||||
"protocol": "openai",
|
"protocol": "openai",
|
||||||
|
"compatibility": "openai",
|
||||||
"name": "默认渠道",
|
"name": "默认渠道",
|
||||||
"baseUrl": "https://api.example.com",
|
"baseUrl": "https://api.example.com",
|
||||||
"apiKey": "sk-xxx",
|
"apiKey": "sk-xxx",
|
||||||
"models": ["gpt-5.5", "gpt-image-2"],
|
"models": ["gpt-5.5", "gpt-image-2"],
|
||||||
"weight": 1,
|
"weight": 1,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"remark": ""
|
"remark": "",
|
||||||
|
"requestOptions": {
|
||||||
|
"imageResponseFormat": "b64_json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"promptSync": {
|
"promptSync": {
|
||||||
@@ -104,16 +108,20 @@
|
|||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `protocol` | string | 协议,当前为 `openai` |
|
| `protocol` | string | 协议,当前为 `openai` |
|
||||||
|
| `compatibility` | string | 接口兼容模式,支持 `openai`、`sub2api`;选择 `sub2api` 时图片请求默认使用 URL 返回格式 |
|
||||||
| `name` | string | 渠道名称 |
|
| `name` | string | 渠道名称 |
|
||||||
| `baseUrl` | string | OpenAI 兼容接口地址 |
|
| `baseUrl` | string | OpenAI 兼容接口地址;可填写带 `/v1` 的 Sub2API Base URL,后端不会重复追加 `/v1` |
|
||||||
| `apiKey` | string | 渠道密钥 |
|
| `apiKey` | string | 渠道密钥 |
|
||||||
| `models` | string[] | 该渠道可用模型 |
|
| `models` | string[] | 该渠道可用模型 |
|
||||||
| `weight` | number | 渠道权重;同一模型有多个可用渠道时按权重随机 |
|
| `weight` | number | 渠道权重;同一模型有多个可用渠道时按权重随机 |
|
||||||
| `enabled` | boolean | 是否启用 |
|
| `enabled` | boolean | 是否启用 |
|
||||||
| `remark` | string | 备注 |
|
| `remark` | string | 备注 |
|
||||||
|
| `requestOptions.imageResponseFormat` | string | 图片生成/编辑请求的 `response_format`,支持 `b64_json`、`url`;Sub2API 默认 `url` |
|
||||||
|
|
||||||
后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。
|
后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。
|
||||||
|
|
||||||
|
Sub2API 接入建议:在管理员设置的模型渠道中新增渠道,接口兼容模式选择 `Sub2API`,`baseUrl` 填 Sub2API 提供的 OpenAI 兼容地址(例如 `https://你的域名/v1`),`apiKey` 填 Sub2API 生成的密钥,并配置该 key 可用的模型。图片生成/编辑会自动携带 `response_format: "url"`,前端仍兼容 URL 与 Base64 两种返回。
|
||||||
|
|
||||||
`promptSync` 字段:
|
`promptSync` 字段:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/basketikun/infinite-canvas/model"
|
||||||
"github.com/basketikun/infinite-canvas/service"
|
"github.com/basketikun/infinite-canvas/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,6 +86,12 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
|
|||||||
Fail(w, "AI 接口请求失败")
|
Fail(w, "AI 接口请求失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
body, contentType, err = adaptAIRequestForChannel(body, contentType, path, channel)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AI proxy adapt request failed: model=%s path=%s err=%v", modelName, path, err)
|
||||||
|
Fail(w, "AI 接口请求失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
path = resolveAIProxyPath(channel.BaseURL, modelName, path)
|
path = resolveAIProxyPath(channel.BaseURL, modelName, path)
|
||||||
request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body))
|
request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,6 +148,90 @@ func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func
|
|||||||
_, _ = io.Copy(w, response.Body)
|
_, _ = io.Copy(w, response.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func adaptAIRequestForChannel(body []byte, contentType string, path string, channel model.ModelChannel) ([]byte, string, error) {
|
||||||
|
if path != "/images/generations" && path != "/images/edits" {
|
||||||
|
return body, contentType, nil
|
||||||
|
}
|
||||||
|
format := strings.ToLower(strings.TrimSpace(channel.RequestOptions.ImageResponseFormat))
|
||||||
|
if format == "" {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(channel.Compatibility), "sub2api") {
|
||||||
|
format = "url"
|
||||||
|
} else {
|
||||||
|
format = "b64_json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if format != "url" && format != "b64_json" {
|
||||||
|
return body, contentType, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||||
|
return setMultipartField(body, contentType, "response_format", format)
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
payload["response_format"] = format
|
||||||
|
nextBody, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return nextBody, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMultipartField(body []byte, contentType string, fieldName string, value string) ([]byte, string, error) {
|
||||||
|
_, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&buffer)
|
||||||
|
written := false
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if part.FormName() == fieldName && part.FileName() == "" {
|
||||||
|
if err := writer.WriteField(fieldName, value); err != nil {
|
||||||
|
_ = part.Close()
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
written = true
|
||||||
|
_ = part.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
header := make(textproto.MIMEHeader, len(part.Header))
|
||||||
|
for key, values := range part.Header {
|
||||||
|
copied := make([]string, len(values))
|
||||||
|
copy(copied, values)
|
||||||
|
header[key] = copied
|
||||||
|
}
|
||||||
|
target, err := writer.CreatePart(header)
|
||||||
|
if err != nil {
|
||||||
|
_ = part.Close()
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(target, part); err != nil {
|
||||||
|
_ = part.Close()
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
_ = part.Close()
|
||||||
|
}
|
||||||
|
if !written {
|
||||||
|
if err := writer.WriteField(fieldName, value); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), writer.FormDataContentType(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func readAIRequest(r *http.Request) ([]byte, string, string, error) {
|
func readAIRequest(r *http.Request) ([]byte, string, string, error) {
|
||||||
contentType := r.Header.Get("Content-Type")
|
contentType := r.Header.Get("Content-Type")
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/basketikun/infinite-canvas/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAIUpstreamErrorDetail(t *testing.T) {
|
func TestAIUpstreamErrorDetail(t *testing.T) {
|
||||||
@@ -25,3 +30,51 @@ func TestSafeUpstreamTextTruncates(t *testing.T) {
|
|||||||
t.Fatalf("truncated rune length = %d", len([]rune(got)))
|
t.Fatalf("truncated rune length = %d", len([]rune(got)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdaptAIRequestForSub2APIImagesUsesURLResponseFormat(t *testing.T) {
|
||||||
|
body, contentType, err := adaptAIRequestForChannel([]byte(`{"model":"gpt-image-1","prompt":"cat"}`), "application/json", "/images/generations", model.ModelChannel{Compatibility: "sub2api"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adaptAIRequestForChannel returned error: %v", err)
|
||||||
|
}
|
||||||
|
if contentType != "application/json" {
|
||||||
|
t.Fatalf("contentType = %q", contentType)
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("invalid json body: %v", err)
|
||||||
|
}
|
||||||
|
if got := payload["response_format"]; got != "url" {
|
||||||
|
t.Fatalf("response_format = %#v, want url", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptAIRequestForSub2APIImageEditsUpdatesMultipartResponseFormat(t *testing.T) {
|
||||||
|
var input bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&input)
|
||||||
|
_ = writer.WriteField("model", "gpt-image-1")
|
||||||
|
_ = writer.WriteField("response_format", "b64_json")
|
||||||
|
part, err := writer.CreateFormFile("image", "input.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = part.Write([]byte("png"))
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("Close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, contentType, err := adaptAIRequestForChannel(input.Bytes(), writer.FormDataContentType(), "/images/edits", model.ModelChannel{Compatibility: "sub2api"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adaptAIRequestForChannel returned error: %v", err)
|
||||||
|
}
|
||||||
|
reader := multipart.NewReader(bytes.NewReader(body), strings.TrimPrefix(contentType, "multipart/form-data; boundary="))
|
||||||
|
form, err := reader.ReadForm(1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadForm: %v", err)
|
||||||
|
}
|
||||||
|
if got := form.Value["response_format"]; len(got) != 1 || got[0] != "url" {
|
||||||
|
t.Fatalf("response_format = %#v, want [url]", got)
|
||||||
|
}
|
||||||
|
if files := form.File["image"]; len(files) != 1 || files[0].Filename != "input.png" {
|
||||||
|
t.Fatalf("image file = %#v", files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+17
-8
@@ -11,14 +11,23 @@ const (
|
|||||||
|
|
||||||
// ModelChannel 模型渠道配置。
|
// ModelChannel 模型渠道配置。
|
||||||
type ModelChannel struct {
|
type ModelChannel struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Name string `json:"name"`
|
Compatibility string `json:"compatibility"`
|
||||||
BaseURL string `json:"baseUrl"`
|
Name string `json:"name"`
|
||||||
APIKey string `json:"apiKey"`
|
BaseURL string `json:"baseUrl"`
|
||||||
Models []string `json:"models"`
|
APIKey string `json:"apiKey"`
|
||||||
Weight int `json:"weight"`
|
Models []string `json:"models"`
|
||||||
Enabled bool `json:"enabled"`
|
Weight int `json:"weight"`
|
||||||
Remark string `json:"remark"`
|
Enabled bool `json:"enabled"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
RequestOptions ModelRequestOptions `json:"requestOptions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelRequestOptions 控制 OpenAI 兼容接口的可选请求参数。
|
||||||
|
type ModelRequestOptions struct {
|
||||||
|
// ImageResponseFormat 为图片生成/编辑请求设置 response_format。
|
||||||
|
// OpenAI 默认使用 b64_json;Sub2API 推荐使用 url,避免部分上游不返回 b64_json。
|
||||||
|
ImageResponseFormat string `json:"imageResponseFormat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelCost 模型算力点配置。
|
// ModelCost 模型算力点配置。
|
||||||
|
|||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
APP_NAME="infinite-canvas"
|
||||||
|
REPO_URL="${REPO_URL:-https://gitea.chickliu.fun/Hermes/infinite-canvas.git}"
|
||||||
|
APP_DIR="${APP_DIR:-$HOME/.Hermes/workspace/git/infinite-canvas}"
|
||||||
|
HOST="${HOST:-0.0.0.0}"
|
||||||
|
WEB_PORT="${WEB_PORT:-3000}"
|
||||||
|
API_PORT="${API_PORT:-8080}"
|
||||||
|
DATA_DIR="${DATA_DIR:-$APP_DIR/data}"
|
||||||
|
DB_PATH="${DATABASE_DSN:-$DATA_DIR/infinite-canvas.db}"
|
||||||
|
PROMPT_DATA_DIR="${PROMPT_DATA_DIR:-$DATA_DIR/prompts}"
|
||||||
|
LOG_DIR="${LOG_DIR:-$APP_DIR/logs}"
|
||||||
|
PID_DIR="${PID_DIR:-$APP_DIR/.pids}"
|
||||||
|
MODE="${1:-start}"
|
||||||
|
|
||||||
|
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; }
|
||||||
|
fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
need_cmd() { command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1"; }
|
||||||
|
|
||||||
|
ensure_repo() {
|
||||||
|
if [ -d "$APP_DIR/.git" ]; then
|
||||||
|
info "使用现有代码目录:$APP_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
info "克隆代码到:$APP_DIR"
|
||||||
|
mkdir -p "$(dirname "$APP_DIR")"
|
||||||
|
git clone "$REPO_URL" "$APP_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_deps() {
|
||||||
|
need_cmd git
|
||||||
|
need_cmd go
|
||||||
|
need_cmd npm
|
||||||
|
mkdir -p "$DATA_DIR" "$PROMPT_DATA_DIR" "$LOG_DIR" "$PID_DIR"
|
||||||
|
if [ ! -d "$APP_DIR/web/node_modules" ]; then
|
||||||
|
info "安装前端依赖"
|
||||||
|
(cd "$APP_DIR/web" && npm install --legacy-peer-deps)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
kill_pid_file() {
|
||||||
|
local file="$1"
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
local pid
|
||||||
|
pid="$(cat "$file" 2>/dev/null || true)"
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "$file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_app() {
|
||||||
|
info "停止 $APP_NAME 源码服务"
|
||||||
|
kill_pid_file "$PID_DIR/backend.pid"
|
||||||
|
kill_pid_file "$PID_DIR/frontend.pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_app() {
|
||||||
|
ensure_repo
|
||||||
|
ensure_deps
|
||||||
|
stop_app
|
||||||
|
|
||||||
|
info "启动后端:http://127.0.0.1:$API_PORT"
|
||||||
|
(
|
||||||
|
cd "$APP_DIR"
|
||||||
|
PORT="$API_PORT" \
|
||||||
|
DATABASE_DSN="$DB_PATH" \
|
||||||
|
PROMPT_DATA_DIR="$PROMPT_DATA_DIR" \
|
||||||
|
nohup go run . >>"$LOG_DIR/backend.log" 2>&1 &
|
||||||
|
echo $! >"$PID_DIR/backend.pid"
|
||||||
|
)
|
||||||
|
|
||||||
|
info "启动前端:http://127.0.0.1:$WEB_PORT"
|
||||||
|
(
|
||||||
|
cd "$APP_DIR/web"
|
||||||
|
API_BASE_URL="http://127.0.0.1:$API_PORT" \
|
||||||
|
nohup npm run dev -- -H "$HOST" -p "$WEB_PORT" >>"$LOG_DIR/frontend.log" 2>&1 &
|
||||||
|
echo $! >"$PID_DIR/frontend.pid"
|
||||||
|
)
|
||||||
|
|
||||||
|
info "等待服务就绪"
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
if curl -fsS "http://127.0.0.1:$API_PORT/api/health" >/dev/null 2>&1 && \
|
||||||
|
curl -fsS "http://127.0.0.1:$WEB_PORT/api/health" >/dev/null 2>&1; then
|
||||||
|
info "启动完成"
|
||||||
|
printf '前端: http://127.0.0.1:%s\n' "$WEB_PORT"
|
||||||
|
printf '后端: http://127.0.0.1:%s\n' "$API_PORT"
|
||||||
|
printf '账号: admin\n'
|
||||||
|
printf '默认密码: infinite-canvas(如已改过,以数据库中现有用户为准)\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
warn "服务未在预期时间内完成健康检查,请查看日志:"
|
||||||
|
printf ' tail -f %q %q\n' "$LOG_DIR/backend.log" "$LOG_DIR/frontend.log"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
status_app() {
|
||||||
|
for name in backend frontend; do
|
||||||
|
local file="$PID_DIR/$name.pid"
|
||||||
|
if [ -f "$file" ] && kill -0 "$(cat "$file")" 2>/dev/null; then
|
||||||
|
printf '%s: running pid=%s\n' "$name" "$(cat "$file")"
|
||||||
|
else
|
||||||
|
printf '%s: stopped\n' "$name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
curl -fsS "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null && printf ' backend_http=ok\n' || true
|
||||||
|
curl -fsS "http://127.0.0.1:$WEB_PORT/api/health" 2>/dev/null && printf ' frontend_proxy=ok\n' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
start|install) start_app ;;
|
||||||
|
stop) stop_app ;;
|
||||||
|
restart) stop_app; start_app ;;
|
||||||
|
status) status_app ;;
|
||||||
|
*)
|
||||||
|
cat <<USAGE
|
||||||
|
Usage: $0 [start|install|stop|restart|status]
|
||||||
|
|
||||||
|
Environment overrides:
|
||||||
|
APP_DIR=$APP_DIR
|
||||||
|
REPO_URL=$REPO_URL
|
||||||
|
WEB_PORT=$WEB_PORT
|
||||||
|
API_PORT=$API_PORT
|
||||||
|
HOST=$HOST
|
||||||
|
USAGE
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+108
-1
@@ -129,6 +129,8 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
|
|||||||
if setting.Channels[i].Protocol == "" {
|
if setting.Channels[i].Protocol == "" {
|
||||||
setting.Channels[i].Protocol = "openai"
|
setting.Channels[i].Protocol = "openai"
|
||||||
}
|
}
|
||||||
|
setting.Channels[i].Compatibility = normalizeChannelCompatibility(setting.Channels[i].Compatibility)
|
||||||
|
setting.Channels[i].RequestOptions = normalizeModelRequestOptions(setting.Channels[i].Compatibility, setting.Channels[i].RequestOptions)
|
||||||
if setting.Channels[i].Models == nil {
|
if setting.Channels[i].Models == nil {
|
||||||
setting.Channels[i].Models = []string{}
|
setting.Channels[i].Models = []string{}
|
||||||
}
|
}
|
||||||
@@ -202,12 +204,54 @@ func SelectModelChannel(modelName string) (model.ModelChannel, error) {
|
|||||||
func BuildModelChannelURL(channel model.ModelChannel, path string) string {
|
func BuildModelChannelURL(channel model.ModelChannel, path string) string {
|
||||||
baseURL := normalizeModelChannelBaseURL(channel.BaseURL)
|
baseURL := normalizeModelChannelBaseURL(channel.BaseURL)
|
||||||
lowerBaseURL := strings.ToLower(baseURL)
|
lowerBaseURL := strings.ToLower(baseURL)
|
||||||
if !strings.HasSuffix(lowerBaseURL, "/v1") && !strings.HasSuffix(lowerBaseURL, "/api/v3") && !strings.HasSuffix(lowerBaseURL, "/api/plan/v3") {
|
if !hasModelChannelVersionSuffix(lowerBaseURL) && !strings.HasSuffix(lowerBaseURL, "/api/v3") && !strings.HasSuffix(lowerBaseURL, "/api/plan/v3") {
|
||||||
baseURL += "/v1"
|
baseURL += "/v1"
|
||||||
}
|
}
|
||||||
return baseURL + path
|
return baseURL + path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasModelChannelVersionSuffix(baseURL string) bool {
|
||||||
|
pathValue := baseURL
|
||||||
|
if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" {
|
||||||
|
pathValue = parsed.Path
|
||||||
|
}
|
||||||
|
pathValue = strings.TrimRight(pathValue, "/")
|
||||||
|
if pathValue == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
segment := pathValue
|
||||||
|
if slash := strings.LastIndex(pathValue, "/"); slash >= 0 {
|
||||||
|
segment = pathValue[slash+1:]
|
||||||
|
}
|
||||||
|
segment = strings.ToLower(strings.TrimSpace(segment))
|
||||||
|
if len(segment) < 2 || segment[0] != 'v' || !isASCIIDigit(segment[1]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
i := 1
|
||||||
|
for i < len(segment) && isASCIIDigit(segment[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == len(segment) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if segment[i] == '.' {
|
||||||
|
i++
|
||||||
|
if i == len(segment) || !isASCIIDigit(segment[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i < len(segment) && isASCIIDigit(segment[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i == len(segment)
|
||||||
|
}
|
||||||
|
suffix := segment[i:]
|
||||||
|
return strings.HasPrefix(suffix, "alpha") || strings.HasPrefix(suffix, "beta") || strings.HasPrefix(suffix, "preview")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIDigit(ch byte) bool {
|
||||||
|
return ch >= '0' && ch <= '9'
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeModelChannelBaseURL(baseURL string) string {
|
func normalizeModelChannelBaseURL(baseURL string) string {
|
||||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
parsed, err := url.Parse(baseURL)
|
parsed, err := url.Parse(baseURL)
|
||||||
@@ -299,6 +343,8 @@ func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel {
|
|||||||
if channel.Protocol == "" {
|
if channel.Protocol == "" {
|
||||||
channel.Protocol = "openai"
|
channel.Protocol = "openai"
|
||||||
}
|
}
|
||||||
|
channel.Compatibility = normalizeChannelCompatibility(channel.Compatibility)
|
||||||
|
channel.RequestOptions = normalizeModelRequestOptions(channel.Compatibility, channel.RequestOptions)
|
||||||
if channel.Models == nil {
|
if channel.Models == nil {
|
||||||
channel.Models = []string{}
|
channel.Models = []string{}
|
||||||
}
|
}
|
||||||
@@ -308,6 +354,30 @@ func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel {
|
|||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeChannelCompatibility(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "sub2api":
|
||||||
|
return "sub2api"
|
||||||
|
default:
|
||||||
|
return "openai"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeModelRequestOptions(compatibility string, options model.ModelRequestOptions) model.ModelRequestOptions {
|
||||||
|
format := strings.ToLower(strings.TrimSpace(options.ImageResponseFormat))
|
||||||
|
switch format {
|
||||||
|
case "url", "b64_json":
|
||||||
|
options.ImageResponseFormat = format
|
||||||
|
default:
|
||||||
|
if normalizeChannelCompatibility(compatibility) == "sub2api" {
|
||||||
|
options.ImageResponseFormat = "url"
|
||||||
|
} else {
|
||||||
|
options.ImageResponseFormat = "b64_json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
func resolveAdminChannel(index *int, channel model.ModelChannel) (model.ModelChannel, error) {
|
func resolveAdminChannel(index *int, channel model.ModelChannel) (model.ModelChannel, error) {
|
||||||
resolved := normalizeModelChannel(channel)
|
resolved := normalizeModelChannel(channel)
|
||||||
if strings.TrimSpace(resolved.APIKey) == "" {
|
if strings.TrimSpace(resolved.APIKey) == "" {
|
||||||
@@ -380,6 +450,9 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string
|
|||||||
if strings.TrimSpace(modelName) == "" {
|
if strings.TrimSpace(modelName) == "" {
|
||||||
return "", errors.New("缺少模型名称")
|
return "", errors.New("缺少模型名称")
|
||||||
}
|
}
|
||||||
|
if isImageModelName(modelName) {
|
||||||
|
return testAdminImageChannelModel(channel, modelName)
|
||||||
|
}
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"model": modelName,
|
"model": modelName,
|
||||||
"messages": []map[string]string{{
|
"messages": []map[string]string{{
|
||||||
@@ -416,6 +489,40 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string
|
|||||||
return "ok", nil
|
return "ok", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAdminImageChannelModel(channel model.ModelChannel, modelName string) (string, error) {
|
||||||
|
format := strings.ToLower(strings.TrimSpace(channel.RequestOptions.ImageResponseFormat))
|
||||||
|
if format == "" {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(channel.Compatibility), "sub2api") {
|
||||||
|
format = "url"
|
||||||
|
} else {
|
||||||
|
format = "b64_json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"model": modelName,
|
||||||
|
"prompt": "test",
|
||||||
|
"n": 1,
|
||||||
|
"size": "1024x1024",
|
||||||
|
"response_format": format,
|
||||||
|
})
|
||||||
|
request, err := http.NewRequest(http.MethodPost, BuildModelChannelURL(channel, "/images/generations"), strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := adminModelHTTPClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", safeMessageError{message: "测试失败:上游接口无响应或网络不可达"}
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
responseBody, _ := io.ReadAll(response.Body)
|
||||||
|
if response.StatusCode >= http.StatusBadRequest {
|
||||||
|
return "", readAdminChannelError(responseBody, response.StatusCode, "测试失败")
|
||||||
|
}
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
|
||||||
func testArkSeedanceChannelModel(channel model.ModelChannel, modelName string) (string, error) {
|
func testArkSeedanceChannelModel(channel model.ModelChannel, modelName string) (string, error) {
|
||||||
if strings.TrimSpace(modelName) == "" {
|
if strings.TrimSpace(modelName) == "" {
|
||||||
return "", errors.New("缺少模型名称")
|
return "", errors.New("缺少模型名称")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -61,6 +62,56 @@ func TestBuildModelChannelURLNormalizesArkPlanTaskPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildModelChannelURLAcceptsVersionedSub2APIBaseURL(t *testing.T) {
|
||||||
|
got := BuildModelChannelURL(model.ModelChannel{BaseURL: "https://sub2api.example.com/v1"}, "/images/generations")
|
||||||
|
want := "https://sub2api.example.com/v1/images/generations"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("BuildModelChannelURL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePrivateSettingDefaultsSub2APIImageResponseFormatToURL(t *testing.T) {
|
||||||
|
setting := normalizePrivateSetting(model.PrivateSetting{Channels: []model.ModelChannel{{Compatibility: "sub2api"}}})
|
||||||
|
if got := setting.Channels[0].RequestOptions.ImageResponseFormat; got != "url" {
|
||||||
|
t.Fatalf("image response format = %q, want url", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestChannelModelUsesImageGenerationProbeForImageModels(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotPayload map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
|
||||||
|
t.Fatalf("Decode request body: %v", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/image.png"}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
result, err := testAdminChannelModel(model.ModelChannel{
|
||||||
|
Compatibility: "sub2api",
|
||||||
|
BaseURL: server.URL + "/v1",
|
||||||
|
APIKey: "test-key",
|
||||||
|
}, "gpt-image-2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("testAdminChannelModel returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result != "ok" {
|
||||||
|
t.Fatalf("result = %q, want ok", result)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/images/generations" {
|
||||||
|
t.Fatalf("path = %q, want /v1/images/generations", gotPath)
|
||||||
|
}
|
||||||
|
if gotPayload["model"] != "gpt-image-2" {
|
||||||
|
t.Fatalf("model = %#v", gotPayload["model"])
|
||||||
|
}
|
||||||
|
if gotPayload["response_format"] != "url" {
|
||||||
|
t.Fatalf("response_format = %#v, want url", gotPayload["response_format"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeSettingsPublishesEnabledChannelModelsAndRepairsDefaults(t *testing.T) {
|
func TestNormalizeSettingsPublishesEnabledChannelModelsAndRepairsDefaults(t *testing.T) {
|
||||||
settings := normalizeSettings(model.Settings{
|
settings := normalizeSettings(model.Settings{
|
||||||
Public: model.PublicSetting{
|
Public: model.PublicSetting{
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const emptySettings: AdminSettings = {
|
|||||||
},
|
},
|
||||||
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } },
|
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } },
|
||||||
};
|
};
|
||||||
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
|
const emptyChannel: AdminModelChannel = { protocol: "openai", compatibility: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "", requestOptions: { imageResponseFormat: "b64_json" } };
|
||||||
|
|
||||||
type SettingsTabKey = "public" | "private";
|
type SettingsTabKey = "public" | "private";
|
||||||
type EditorMode = "visual" | "json";
|
type EditorMode = "visual" | "json";
|
||||||
@@ -467,15 +467,17 @@ export default function AdminSettingsPage() {
|
|||||||
dataIndex: "credits",
|
dataIndex: "credits",
|
||||||
width: 220,
|
width: 220,
|
||||||
render: (_, item) => (
|
render: (_, item) => (
|
||||||
<InputNumber
|
<Space.Compact className="!w-full">
|
||||||
min={0}
|
<InputNumber
|
||||||
step={1}
|
min={0}
|
||||||
precision={0}
|
step={1}
|
||||||
className="!w-full"
|
precision={0}
|
||||||
value={item.credits}
|
className="!w-full"
|
||||||
addonAfter="点"
|
value={item.credits}
|
||||||
onChange={(value) => setModelCost(form, setModelCosts, item.model, Number(value) || 0)}
|
onChange={(value) => setModelCost(form, setModelCosts, item.model, Number(value) || 0)}
|
||||||
/>
|
/>
|
||||||
|
<Button disabled>点</Button>
|
||||||
|
</Space.Compact>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -558,6 +560,7 @@ export default function AdminSettingsPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
|
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
|
||||||
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
|
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
|
||||||
|
{ title: "兼容", dataIndex: "compatibility", width: 110, render: (value) => <Tag color={value === "sub2api" ? "blue" : "default"}>{value === "sub2api" ? "Sub2API" : "OpenAI"}</Tag> },
|
||||||
{ title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> },
|
{ title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> },
|
||||||
{
|
{
|
||||||
title: "模型",
|
title: "模型",
|
||||||
@@ -640,6 +643,20 @@ export default function AdminSettingsPage() {
|
|||||||
<Select options={[{ label: "OpenAI", value: "openai" }]} />
|
<Select options={[{ label: "OpenAI", value: "openai" }]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="compatibility" label="接口兼容模式" extra="选择 Sub2API 后,图片请求默认使用 URL 返回格式,Base URL 仍填写 Sub2API 的 OpenAI 兼容地址。">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ label: "OpenAI 兼容", value: "openai" },
|
||||||
|
{ label: "Sub2API", value: "sub2api" },
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value === "sub2api") channelForm.setFieldValue(["requestOptions", "imageResponseFormat"], "url");
|
||||||
|
if (value === "openai") channelForm.setFieldValue(["requestOptions", "imageResponseFormat"], "b64_json");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="weight" label="权重">
|
<Form.Item name="weight" label="权重">
|
||||||
<InputNumber min={1} step={1} className="!w-full" />
|
<InputNumber min={1} step={1} className="!w-full" />
|
||||||
@@ -670,6 +687,16 @@ export default function AdminSettingsPage() {
|
|||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item name={["requestOptions", "imageResponseFormat"]} label="图片返回格式" extra="Sub2API 推荐 url;OpenAI 官方也支持 b64_json。前端两种返回都能解析。">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ label: "URL(推荐 Sub2API)", value: "url" },
|
||||||
|
{ label: "Base64 JSON", value: "b64_json" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.Item name="remark" label="备注">
|
<Form.Item name="remark" label="备注">
|
||||||
<Input.TextArea rows={3} />
|
<Input.TextArea rows={3} />
|
||||||
@@ -870,8 +897,11 @@ function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChannel {
|
function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChannel {
|
||||||
|
const compatibility = item.compatibility === "sub2api" ? "sub2api" : "openai";
|
||||||
|
const imageResponseFormat = item.requestOptions?.imageResponseFormat === "url" || item.requestOptions?.imageResponseFormat === "b64_json" ? item.requestOptions.imageResponseFormat : compatibility === "sub2api" ? "url" : "b64_json";
|
||||||
return {
|
return {
|
||||||
protocol: "openai",
|
protocol: "openai",
|
||||||
|
compatibility,
|
||||||
name: item.name || "",
|
name: item.name || "",
|
||||||
baseUrl: item.baseUrl || "",
|
baseUrl: item.baseUrl || "",
|
||||||
apiKey: item.apiKey || "",
|
apiKey: item.apiKey || "",
|
||||||
@@ -879,6 +909,7 @@ function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChan
|
|||||||
weight: Math.max(1, Number(item.weight) || 1),
|
weight: Math.max(1, Number(item.weight) || 1),
|
||||||
enabled: item.enabled !== false,
|
enabled: item.enabled !== false,
|
||||||
remark: item.remark || "",
|
remark: item.remark || "",
|
||||||
|
requestOptions: { imageResponseFormat },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export async function deleteAdminAsset(token: string, id: string) {
|
|||||||
|
|
||||||
export type AdminModelChannel = {
|
export type AdminModelChannel = {
|
||||||
protocol: "openai";
|
protocol: "openai";
|
||||||
|
compatibility: "openai" | "sub2api";
|
||||||
name: string;
|
name: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
@@ -165,6 +166,9 @@ export type AdminModelChannel = {
|
|||||||
weight: number;
|
weight: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
requestOptions: {
|
||||||
|
imageResponseFormat: "b64_json" | "url";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminPublicModelChannelSettings = {
|
export type AdminPublicModelChannelSettings = {
|
||||||
|
|||||||
Reference in New Issue
Block a user