Files
2026-06-01 23:43:53 +08:00

596 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/repository"
)
var adminModelHTTPClient = &http.Client{Timeout: 30 * time.Second}
func PublicSettings() (model.PublicSetting, error) {
settings, err := repository.GetSettings()
return normalizeSettings(settings).Public, err
}
func AdminSettings() (model.Settings, error) {
settings, err := repository.GetSettings()
return hidePrivateAPIKeys(normalizeSettings(settings)), err
}
func SaveSettings(settings model.Settings) (model.Settings, error) {
saved, err := repository.GetSettings()
if err != nil {
return model.Settings{}, err
}
settings = normalizeSettings(settings)
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
keepPrivateAuthSecrets(&settings, normalizeSettings(saved))
result, err := repository.SaveSettings(settings, now())
if err == nil {
RefreshPromptSyncScheduler()
}
return hidePrivateAPIKeys(result), err
}
func AdminChannelModels(index *int, channel model.ModelChannel) ([]string, error) {
resolved, err := resolveAdminChannel(index, channel)
if err != nil {
return nil, err
}
return fetchAdminChannelModels(resolved)
}
func AdminTestChannelModel(index *int, channel model.ModelChannel, modelName string) (string, error) {
resolved, err := resolveAdminChannel(index, channel)
if err != nil {
return "", err
}
if isArkAgentPlanChannel(resolved) || isSeedanceModelName(modelName) {
return testArkSeedanceChannelModel(resolved, modelName)
}
return testAdminChannelModel(resolved, modelName)
}
func normalizeSettings(settings model.Settings) model.Settings {
settings.Private = normalizePrivateSetting(settings.Private)
settings.Public = normalizePublicSettingWithChannels(settings.Public, settings.Private.Channels)
return settings
}
func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting {
return normalizePublicSettingWithChannels(setting, nil)
}
func normalizePublicSettingWithChannels(setting model.PublicSetting, channels []model.ModelChannel) model.PublicSetting {
if setting.ModelChannel.AvailableModels == nil {
setting.ModelChannel.AvailableModels = []string{}
}
if setting.ModelChannel.ModelCosts == nil {
setting.ModelChannel.ModelCosts = []model.ModelCost{}
}
for i := range setting.ModelChannel.ModelCosts {
setting.ModelChannel.ModelCosts[i].Model = strings.TrimSpace(setting.ModelChannel.ModelCosts[i].Model)
if setting.ModelChannel.ModelCosts[i].Credits < 0 {
setting.ModelChannel.ModelCosts[i].Credits = 0
}
}
if setting.ModelChannel.AllowCustomChannel == nil {
enabled := true
setting.ModelChannel.AllowCustomChannel = &enabled
}
if setting.Auth.AllowRegister == nil {
enabled := true
setting.Auth.AllowRegister = &enabled
}
enabledModels := enabledChannelModels(channels)
if len(enabledModels) > 0 {
setting.ModelChannel.AvailableModels = enabledModels
} else {
setting.ModelChannel.AvailableModels = uniqueModelNames(setting.ModelChannel.AvailableModels)
}
setting.ModelChannel.DefaultTextModel = repairDefaultModel(setting.ModelChannel.DefaultTextModel, setting.ModelChannel.AvailableModels, isTextModelName)
setting.ModelChannel.DefaultImageModel = repairDefaultModel(setting.ModelChannel.DefaultImageModel, setting.ModelChannel.AvailableModels, isImageModelName)
setting.ModelChannel.DefaultVideoModel = repairDefaultModel(setting.ModelChannel.DefaultVideoModel, setting.ModelChannel.AvailableModels, isVideoModelName)
setting.ModelChannel.DefaultModel = repairDefaultModel(setting.ModelChannel.DefaultModel, setting.ModelChannel.AvailableModels, isTextModelName)
return setting
}
func ModelCost(modelName string) (int, error) {
settings, err := repository.GetSettings()
if err != nil {
return 0, err
}
modelName = strings.TrimSpace(modelName)
for _, item := range normalizePublicSetting(settings.Public).ModelChannel.ModelCosts {
if item.Model == modelName {
return item.Credits, nil
}
}
return 0, nil
}
func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting {
if setting.Channels == nil {
setting.Channels = []model.ModelChannel{}
}
setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync)
for i := range setting.Channels {
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{}
}
if setting.Channels[i].Weight <= 0 {
setting.Channels[i].Weight = 1
}
}
return setting
}
func hidePrivateAPIKeys(settings model.Settings) model.Settings {
for i := range settings.Private.Channels {
settings.Private.Channels[i].APIKey = ""
}
settings.Private.Auth.LinuxDo.ClientSecret = ""
return settings
}
func keepPrivateAPIKeys(settings *model.Settings, saved model.Settings) {
for i := range settings.Private.Channels {
if strings.TrimSpace(settings.Private.Channels[i].APIKey) != "" {
continue
}
if channel, ok := findSavedChannel(settings.Private.Channels[i], saved.Private.Channels, i); ok {
settings.Private.Channels[i].APIKey = channel.APIKey
}
}
}
func keepPrivateAuthSecrets(settings *model.Settings, saved model.Settings) {
if strings.TrimSpace(settings.Private.Auth.LinuxDo.ClientSecret) == "" {
settings.Private.Auth.LinuxDo.ClientSecret = saved.Private.Auth.LinuxDo.ClientSecret
}
}
func findSavedChannel(channel model.ModelChannel, saved []model.ModelChannel, index int) (model.ModelChannel, bool) {
for _, item := range saved {
if item.Name == channel.Name && item.BaseURL == channel.BaseURL {
return item, true
}
}
if index < len(saved) {
return saved[index], true
}
return model.ModelChannel{}, false
}
func SelectModelChannel(modelName string) (model.ModelChannel, error) {
settings, err := repository.GetSettings()
if err != nil {
return model.ModelChannel{}, err
}
channels := modelChannelsForModel(normalizePrivateSetting(settings.Private).Channels, modelName)
if len(channels) == 0 {
return model.ModelChannel{}, errors.New("没有可用模型渠道")
}
total := 0
for _, channel := range channels {
total += channel.Weight
}
hit := rand.Intn(total)
for _, channel := range channels {
hit -= channel.Weight
if hit < 0 {
return channel, nil
}
}
return channels[0], nil
}
func BuildModelChannelURL(channel model.ModelChannel, path string) string {
baseURL := normalizeModelChannelBaseURL(channel.BaseURL)
lowerBaseURL := strings.ToLower(baseURL)
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)
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
}
func isArkAgentPlanChannel(channel model.ModelChannel) bool {
baseURL := strings.ToLower(normalizeModelChannelBaseURL(channel.BaseURL))
return strings.HasSuffix(baseURL, "/api/plan/v3")
}
func isSeedanceModelName(modelName string) bool {
modelName = strings.ToLower(strings.TrimSpace(modelName))
return strings.Contains(modelName, "seedance") || strings.Contains(modelName, "doubao-seedance")
}
func enabledChannelModels(channels []model.ModelChannel) []string {
models := []string{}
for _, channel := range channels {
if !channel.Enabled {
continue
}
models = append(models, channel.Models...)
}
return uniqueModelNames(models)
}
func uniqueModelNames(models []string) []string {
result := []string{}
seen := map[string]bool{}
for _, item := range models {
name := strings.TrimSpace(item)
if name == "" || seen[name] {
continue
}
seen[name] = true
result = append(result, name)
}
return result
}
func repairDefaultModel(current string, models []string, preferred func(string) bool) string {
current = strings.TrimSpace(current)
for _, item := range models {
if item == current {
return current
}
}
for _, item := range models {
if preferred(item) {
return item
}
}
if len(models) > 0 {
return models[0]
}
return ""
}
func isVideoModelName(modelName string) bool {
name := strings.ToLower(strings.TrimSpace(modelName))
return strings.Contains(name, "seedance") || strings.Contains(name, "video")
}
func isImageModelName(modelName string) bool {
name := strings.ToLower(strings.TrimSpace(modelName))
return strings.Contains(name, "seedream") || strings.Contains(name, "gpt-image") || strings.Contains(name, "image")
}
func isTextModelName(modelName string) bool {
return !isImageModelName(modelName) && !isVideoModelName(modelName)
}
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{}
}
if channel.Weight <= 0 {
channel.Weight = 1
}
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) == "" {
settings, err := repository.GetSettings()
if err != nil {
return model.ModelChannel{}, err
}
saved := normalizePrivateSetting(settings.Private).Channels
if index != nil && *index >= 0 && *index < len(saved) {
if resolved.APIKey == "" {
resolved.APIKey = saved[*index].APIKey
}
if resolved.BaseURL == "" {
resolved.BaseURL = saved[*index].BaseURL
}
if resolved.Name == "" {
resolved.Name = saved[*index].Name
}
}
if resolved.APIKey == "" {
if savedChannel, ok := findSavedChannel(resolved, saved, -1); ok {
resolved.APIKey = savedChannel.APIKey
}
}
}
if strings.TrimSpace(resolved.BaseURL) == "" {
return model.ModelChannel{}, safeMessageError{message: "缺少接口地址"}
}
if strings.TrimSpace(resolved.APIKey) == "" {
return model.ModelChannel{}, safeMessageError{message: "缺少 API Key"}
}
return resolved, nil
}
func fetchAdminChannelModels(channel model.ModelChannel) ([]string, error) {
request, err := http.NewRequest(http.MethodGet, BuildModelChannelURL(channel, "/models"), nil)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
response, err := adminModelHTTPClient.Do(request)
if err != nil {
return nil, safeMessageError{message: "读取模型失败:上游接口无响应或网络不可达"}
}
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 {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(body, &payload)
result := make([]string, 0, len(payload.Data))
for _, item := range payload.Data {
if strings.TrimSpace(item.ID) != "" {
result = append(result, item.ID)
}
}
sort.Strings(result)
return result, nil
}
func testAdminChannelModel(channel model.ModelChannel, modelName string) (string, error) {
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{{
"role": "user",
"content": "hi",
}},
})
request, err := http.NewRequest(http.MethodPost, BuildModelChannelURL(channel, "/chat/completions"), 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, "测试失败")
}
var payload struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
_ = json.Unmarshal(responseBody, &payload)
if len(payload.Choices) > 0 && strings.TrimSpace(payload.Choices[0].Message.Content) != "" {
return payload.Choices[0].Message.Content, 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) {
if strings.TrimSpace(modelName) == "" {
return "", errors.New("缺少模型名称")
}
if strings.TrimSpace(channel.BaseURL) == "" {
return "", safeMessageError{message: "缺少接口地址"}
}
if strings.TrimSpace(channel.APIKey) == "" {
return "", safeMessageError{message: "缺少 API Key"}
}
if !isArkAgentPlanChannel(channel) {
return "Seedance 视频模型不会发送 /chat/completions 文本测试。已检查 Base URL、API Key 和模型名非空;未调用视频生成接口,因此未验证套餐额度或模型权限。", nil
}
return "Agent Plan / Seedance 视频模型配置格式已通过。后台测试不会调用视频生成接口,因此未验证 API Key、套餐额度或模型权限;请在画布中使用视频生成验证。", nil
}
func readAdminChannelError(body []byte, statusCode int, fallback string) error {
var payload struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
Msg string `json:"msg"`
}
if len(body) > 0 && json.Unmarshal(body, &payload) == nil {
if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" {
return safeMessageError{message: payload.Error.Message}
}
if strings.TrimSpace(payload.Msg) != "" {
return safeMessageError{message: payload.Msg}
}
}
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)}
}
return safeMessageError{message: fallback}
}
type safeMessageError struct {
message string
}
func (err safeMessageError) Error() string {
return err.message
}
func (err safeMessageError) SafeMessage() string {
return err.message
}
func modelChannelsForModel(channels []model.ModelChannel, modelName string) []model.ModelChannel {
result := []model.ModelChannel{}
for _, channel := range channels {
if !channel.Enabled || channel.BaseURL == "" || channel.APIKey == "" {
continue
}
for _, item := range channel.Models {
if strings.TrimSpace(item) == modelName {
result = append(result, channel)
break
}
}
}
return result
}