Files
ttstd-infinite-canvas/backend/internal/service/channel_model_catalog.go

266 lines
9.7 KiB
Go
Raw 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 (
"context"
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"infinite-canvas/backend/internal/model"
)
type ChannelModelsRequest struct {
BaseURL string `json:"baseUrl"`
AllowLocalChannel bool `json:"allowLocalChannel"`
APIKey string `json:"apiKey"`
APIFormat string `json:"apiFormat"`
Headers []OutboundHeader `json:"headers"`
}
type channelModelsPayload struct {
Data []channelModelItem `json:"data"`
Models []channelModelItem `json:"models"`
Error *providerError `json:"error"`
Code *int `json:"code"`
Msg string `json:"msg"`
}
type channelModelItem struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ModelType string `json:"model_type"`
SupportedEndpointTypes []string `json:"supported_endpoint_types"`
DefaultParameters channelModelCatalogParameters `json:"default_parameters"`
Options channelModelCatalogOptions `json:"options"`
SupportsImages *bool `json:"supports_images"`
MinImages *int `json:"min_images"`
MaxImages *int `json:"max_images"`
}
type channelModelCatalogParameters struct {
AspectRatio string `json:"aspect_ratio"`
DurationSeconds string `json:"duration_seconds"`
Resolution string `json:"resolution"`
}
type channelModelCatalogOptions struct {
AspectRatio []ChannelModelCatalogOption `json:"aspect_ratio"`
DurationSeconds []ChannelModelCatalogOption `json:"duration_seconds"`
Resolution []ChannelModelCatalogOption `json:"resolution"`
}
func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) {
items, err := s.FetchChannelModelCatalog(ctx, actor, input)
if err != nil {
return nil, err
}
seen := make(map[string]bool, len(items))
models := make([]string, 0, len(items))
for _, item := range items {
name := strings.TrimSpace(item.ID)
if name == "" || seen[name] {
continue
}
seen[name] = true
models = append(models, name)
}
sort.Strings(models)
return models, nil
}
// ChannelModelCatalogItem 是前端自定义渠道拉取模型目录后的最小合同;
// 协议、能力和可选参数均来自上游公开元数据,不展开供应商内部兼容模型。
type ChannelModelCatalogItem struct {
ID string `json:"id"`
DisplayName string `json:"displayName,omitempty"`
ModelType string `json:"modelType,omitempty"`
SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"`
DefaultParameters ChannelModelCatalogDefaultParameters `json:"defaultParameters,omitempty"`
Options ChannelModelCatalogOptions `json:"options,omitempty"`
SupportsImages *bool `json:"supportsImages,omitempty"`
MinImages *int `json:"minImages,omitempty"`
MaxImages *int `json:"maxImages,omitempty"`
}
type ChannelModelCatalogDefaultParameters struct {
AspectRatio string `json:"aspectRatio,omitempty"`
DurationSeconds string `json:"durationSeconds,omitempty"`
Resolution string `json:"resolution,omitempty"`
}
type ChannelModelCatalogOptions struct {
AspectRatio []ChannelModelCatalogOption `json:"aspectRatio,omitempty"`
DurationSeconds []ChannelModelCatalogOption `json:"durationSeconds,omitempty"`
Resolution []ChannelModelCatalogOption `json:"resolution,omitempty"`
}
type ChannelModelCatalogOption struct {
Value string `json:"value"`
Label string `json:"label,omitempty"`
}
func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
if actor == nil || strings.TrimSpace(actor.ID) == "" {
return nil, Unauthorized("请先登录")
}
baseURL := strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
apiKey := strings.TrimSpace(input.APIKey)
if baseURL == "" {
return nil, BadAuthRequest("请填写 Base URL")
}
if apiKey == "" {
return nil, BadAuthRequest("请填写 API Key")
}
apiFormat := strings.ToLower(strings.TrimSpace(input.APIFormat))
if apiFormat == "" {
apiFormat = "openai"
}
if apiFormat != "openai" && apiFormat != "gemini" {
return nil, BadAuthRequest("接口协议不支持拉取模型")
}
headers, err := NormalizeOutboundHeaders(input.Headers)
if err != nil {
return nil, err
}
target := apiURL(baseURL, "/models")
if apiFormat == "gemini" {
if !strings.HasSuffix(strings.ToLower(baseURL), "/v1beta") {
baseURL += "/v1beta"
}
target = baseURL + "/models"
}
if _, err := s.validateChannelOutboundURL(target, input.AllowLocalChannel, false); err != nil {
return nil, err
}
requestContext := withProviderOutboundPolicy(ctx, providerConfig{BaseURL: baseURL, AllowLocalChannel: s.effectiveAllowLocalChannel(input.AllowLocalChannel)})
request, err := http.NewRequestWithContext(requestContext, http.MethodGet, target, nil)
if err != nil {
return nil, BadAuthRequest("模型服务地址无效")
}
if apiFormat == "gemini" {
request.Header.Set("x-goog-api-key", apiKey)
} else {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
ApplyOutboundHeaders(request, headers)
// 只代理固定的模型目录 GET;用户密钥仅用于本次请求,不写入数据库或日志。
data, _, err := doBinary(request)
if err != nil {
return nil, channelModelsUpstreamError(err)
}
var payload channelModelsPayload
if err := json.Unmarshal(data, &payload); err != nil {
return nil, WrapAppError(http.StatusBadGateway, "模型服务返回的不是有效 JSON", err)
}
if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" {
return nil, NewAppError(http.StatusBadGateway, "模型服务返回失败,请检查渠道配置")
}
if payload.Code != nil && *payload.Code != 0 {
return nil, NewAppError(http.StatusBadGateway, "模型服务返回失败,请检查渠道配置")
}
items := payload.Data
if apiFormat == "gemini" {
items = payload.Models
}
seen := make(map[string]bool, len(items))
catalog := make([]ChannelModelCatalogItem, 0, len(items))
for _, item := range items {
name := strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(item.ID, item.Name)), "models/")
if name == "" || seen[name] {
continue
}
seen[name] = true
catalog = append(catalog, ChannelModelCatalogItem{
ID: name,
DisplayName: strings.TrimSpace(item.DisplayName),
ModelType: normalizeCatalogModelType(item.ModelType),
SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes),
DefaultParameters: ChannelModelCatalogDefaultParameters{
AspectRatio: strings.TrimSpace(item.DefaultParameters.AspectRatio),
DurationSeconds: strings.TrimSpace(item.DefaultParameters.DurationSeconds),
Resolution: strings.TrimSpace(item.DefaultParameters.Resolution),
},
Options: ChannelModelCatalogOptions{
AspectRatio: normalizeCatalogOptions(item.Options.AspectRatio),
DurationSeconds: normalizeCatalogOptions(item.Options.DurationSeconds),
Resolution: normalizeCatalogOptions(item.Options.Resolution),
},
SupportsImages: item.SupportsImages,
MinImages: item.MinImages,
MaxImages: item.MaxImages,
})
}
sort.Slice(catalog, func(left int, right int) bool {
return catalog[left].ID < catalog[right].ID
})
if s.isPluginEnabled() {
catalog = extendChannelModelCatalog(baseURL, apiFormat, headers, catalog)
}
return catalog, nil
}
func normalizeCatalogModelType(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
case "text", "image", "video", "audio":
return normalized
default:
return ""
}
}
func normalizeCatalogOptions(options []ChannelModelCatalogOption) []ChannelModelCatalogOption {
seen := make(map[string]bool, len(options))
normalized := make([]ChannelModelCatalogOption, 0, len(options))
for _, option := range options {
value := strings.TrimSpace(option.Value)
if value == "" || seen[value] {
continue
}
seen[value] = true
normalized = append(normalized, ChannelModelCatalogOption{Value: value, Label: strings.TrimSpace(option.Label)})
}
return normalized
}
func normalizeCatalogEndpointTypes(values []string) []string {
seen := make(map[string]bool, len(values))
normalized := make([]string, 0, len(values))
for _, value := range values {
item := strings.TrimSpace(value)
if item == "" || seen[item] {
continue
}
seen[item] = true
normalized = append(normalized, item)
}
return normalized
}
func channelModelsUpstreamError(err error) error {
var authErr *AuthError
if errors.As(err, &authErr) {
return authErr
}
var httpErr providerHTTPError
if !errors.As(err, &httpErr) {
return WrapAppError(http.StatusBadGateway, "连接模型服务失败,请检查渠道地址和网络", err)
}
switch httpErr.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return NewAppError(http.StatusBadGateway, "模型服务鉴权失败,请检查 API Key")
case http.StatusNotFound:
return NewAppError(http.StatusBadGateway, "模型服务未提供 /models 接口")
case http.StatusTooManyRequests:
return NewAppError(http.StatusBadGateway, "模型服务请求过于频繁或额度不足")
default:
return WrapAppError(http.StatusBadGateway, httpErr.Error(), err)
}
}