feat: add sub2api compatibility
This commit is contained in:
@@ -9,8 +9,10 @@ import (
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
@@ -84,6 +86,12 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
|
||||
Fail(w, "AI 接口请求失败")
|
||||
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)
|
||||
request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -140,6 +148,90 @@ func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func
|
||||
_, _ = 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) {
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
)
|
||||
|
||||
func TestAIUpstreamErrorDetail(t *testing.T) {
|
||||
@@ -25,3 +30,51 @@ func TestSafeUpstreamTextTruncates(t *testing.T) {
|
||||
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 模型渠道配置。
|
||||
type ModelChannel struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Models []string `json:"models"`
|
||||
Weight int `json:"weight"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Remark string `json:"remark"`
|
||||
Protocol string `json:"protocol"`
|
||||
Compatibility string `json:"compatibility"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Models []string `json:"models"`
|
||||
Weight int `json:"weight"`
|
||||
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 模型算力点配置。
|
||||
|
||||
+108
-1
@@ -129,6 +129,8 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
|
||||
if setting.Channels[i].Protocol == "" {
|
||||
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 {
|
||||
setting.Channels[i].Models = []string{}
|
||||
}
|
||||
@@ -202,12 +204,54 @@ func SelectModelChannel(modelName string) (model.ModelChannel, error) {
|
||||
func BuildModelChannelURL(channel model.ModelChannel, path string) string {
|
||||
baseURL := normalizeModelChannelBaseURL(channel.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"
|
||||
}
|
||||
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 {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
parsed, err := url.Parse(baseURL)
|
||||
@@ -299,6 +343,8 @@ func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel {
|
||||
if channel.Protocol == "" {
|
||||
channel.Protocol = "openai"
|
||||
}
|
||||
channel.Compatibility = normalizeChannelCompatibility(channel.Compatibility)
|
||||
channel.RequestOptions = normalizeModelRequestOptions(channel.Compatibility, channel.RequestOptions)
|
||||
if channel.Models == nil {
|
||||
channel.Models = []string{}
|
||||
}
|
||||
@@ -308,6 +354,30 @@ func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel {
|
||||
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) {
|
||||
resolved := normalizeModelChannel(channel)
|
||||
if strings.TrimSpace(resolved.APIKey) == "" {
|
||||
@@ -380,6 +450,9 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
return "", errors.New("缺少模型名称")
|
||||
}
|
||||
if isImageModelName(modelName) {
|
||||
return testAdminImageChannelModel(channel, modelName)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": modelName,
|
||||
"messages": []map[string]string{{
|
||||
@@ -416,6 +489,40 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string
|
||||
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) {
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
return "", errors.New("缺少模型名称")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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) {
|
||||
settings := normalizeSettings(model.Settings{
|
||||
Public: model.PublicSetting{
|
||||
|
||||
@@ -158,6 +158,7 @@ export async function deleteAdminAsset(token: string, id: string) {
|
||||
|
||||
export type AdminModelChannel = {
|
||||
protocol: "openai";
|
||||
compatibility: "openai" | "sub2api";
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
@@ -165,6 +166,9 @@ export type AdminModelChannel = {
|
||||
weight: number;
|
||||
enabled: boolean;
|
||||
remark: string;
|
||||
requestOptions: {
|
||||
imageResponseFormat: "b64_json" | "url";
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminPublicModelChannelSettings = {
|
||||
|
||||
Reference in New Issue
Block a user