@@ -659,6 +659,11 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
channelHeaders, err := service.ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
// 同步代理与后台任务必须共享渠道槽位,否则两条入口会共同超过供应商并发上限。
|
||||
releaseChannel, concurrencyLimit, err := svc.AcquireChannelSlot(c.Request.Context(), channel.ID, "", 36*time.Minute)
|
||||
if err != nil {
|
||||
@@ -694,6 +699,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
if accept := c.GetHeader("Accept"); accept != "" {
|
||||
upstreamReq.Header.Set("Accept", accept)
|
||||
}
|
||||
service.ApplyOutboundHeaders(upstreamReq, channelHeaders)
|
||||
service.ApplyDefaultOutboundHeaders(upstreamReq)
|
||||
if protocol == model.ChannelInterfaceGeminiVeo {
|
||||
upstreamReq.Header.Set("x-goog-api-key", channel.APIKey)
|
||||
|
||||
@@ -64,6 +64,11 @@ func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy
|
||||
fail(c, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
headers, err := service.DecodeRelayOutboundHeaders(c.GetHeader(service.CustomRelayHeadersHeader))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
requestLimit := policy.CustomRelayRequestMB << 20
|
||||
if c.Request.ContentLength > requestLimit {
|
||||
fail(c, http.StatusRequestEntityTooLarge, errors.New("自定义渠道请求超过配置上限"))
|
||||
@@ -97,6 +102,7 @@ func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy
|
||||
} else {
|
||||
upstreamReq.Header.Set("Accept", "application/json")
|
||||
}
|
||||
service.ApplyOutboundHeaders(upstreamReq, headers)
|
||||
service.ApplyDefaultOutboundHeaders(upstreamReq)
|
||||
if apiFormat == "gemini" {
|
||||
upstreamReq.Header.Set("x-goog-api-key", apiKey)
|
||||
@@ -110,10 +116,11 @@ func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
writeCustomRelayResponse(c, resp, apiKey, policy.CustomRelayResponseMB<<20)
|
||||
allowBinary := customVideoContentPath.MatchString(target.EscapedPath()) || strings.HasSuffix(target.Path, "/audio/speech")
|
||||
writeCustomRelayResponse(c, resp, apiKey, policy.CustomRelayResponseMB<<20, allowBinary)
|
||||
}
|
||||
|
||||
func writeCustomRelayResponse(c *gin.Context, resp *http.Response, apiKey string, responseLimit int64) {
|
||||
func writeCustomRelayResponse(c *gin.Context, resp *http.Response, apiKey string, responseLimit int64, allowBinary bool) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
@@ -129,6 +136,15 @@ func writeCustomRelayResponse(c *gin.Context, resp *http.Response, apiKey string
|
||||
copyCustomRelayStream(c, resp.Body, apiKey, responseLimit)
|
||||
return
|
||||
}
|
||||
if allowBinary && (strings.HasPrefix(mediaType, "video/") || strings.HasPrefix(mediaType, "audio/") || mediaType == "application/octet-stream") {
|
||||
body, err := readLimitedRelayBody(resp.Body, responseLimit)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游返回了过大的媒体文件"))
|
||||
return
|
||||
}
|
||||
c.Data(resp.StatusCode, mediaType, body)
|
||||
return
|
||||
}
|
||||
if mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json") {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游返回了不支持的内容类型"))
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -22,9 +23,12 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+apiKey {
|
||||
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.Header.Get("User-Agent") != service.DefaultOutboundUserAgent {
|
||||
if r.Header.Get("User-Agent") != "Custom Relay Agent" {
|
||||
t.Errorf("User-Agent = %q", r.Header.Get("User-Agent"))
|
||||
}
|
||||
if r.Header.Get("X-Gateway-Tenant") != "tenant-a" {
|
||||
t.Errorf("X-Gateway-Tenant = %q", r.Header.Get("X-Gateway-Tenant"))
|
||||
}
|
||||
for _, name := range []string{"Cookie", "Origin", "Referer", "X-Canvas-Upstream-URL", "X-Forwarded-For"} {
|
||||
if value := r.Header.Get(name); value != "" {
|
||||
t.Errorf("upstream received %s = %q", name, value)
|
||||
@@ -43,6 +47,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/models")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
request.Header.Set(service.CustomRelayHeadersHeader, base64.StdEncoding.EncodeToString([]byte(`[{"name":"User-Agent","value":"Custom Relay Agent"},{"name":"X-Gateway-Tenant","value":"tenant-a"}]`)))
|
||||
request.Header.Set("Cookie", "browser=session")
|
||||
request.Header.Set("Origin", "https://canvas.example.com")
|
||||
response := httptest.NewRecorder()
|
||||
@@ -61,6 +66,30 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRelayReturnsVideoContent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("video-content"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
useCustomRelayTestClient(t, upstream.Client())
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/ai/custom", nil)
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/videos/task-1/content")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "video/mp4" || response.Body.String() != "video-content" {
|
||||
t.Fatalf("status = %d, content-type = %q, body = %q", response.Code, response.Header().Get("Content-Type"), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRelayConvertsGeminiAuthentication(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const apiKey = "gemini-secret-key"
|
||||
|
||||
@@ -28,6 +28,7 @@ var (
|
||||
customGeminiRelayPath = regexp.MustCompile(`(?:^|/)models/[^/:]+:(generateContent|streamGenerateContent|predictLongRunning)$`)
|
||||
customVideoTaskPath = regexp.MustCompile(`(?:^|/)video/generations/[^/]+$`)
|
||||
customXAIVideoTaskPath = regexp.MustCompile(`(?:^|/)videos/[^/]+$`)
|
||||
customVideoContentPath = regexp.MustCompile(`(?:^|/)videos/[^/]+/content$`)
|
||||
customArkVideoTaskPath = regexp.MustCompile(`(?:^|/)contents/generations/tasks/[^/]+$`)
|
||||
customGeminiOperationPath = regexp.MustCompile(`(?:^|/)(?:models/[^/]+/)?operations/[^/]+$`)
|
||||
openAIPostEndpoints = map[string]bool{
|
||||
@@ -63,7 +64,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont
|
||||
if method == http.MethodGet {
|
||||
allowed := requestPath == "/models" || strings.HasSuffix(requestPath, "/models")
|
||||
if apiFormat == "openai" {
|
||||
allowed = allowed || customVideoTaskPath.MatchString(requestPath) || customXAIVideoTaskPath.MatchString(requestPath) || customArkVideoTaskPath.MatchString(requestPath)
|
||||
allowed = allowed || customVideoTaskPath.MatchString(requestPath) || customXAIVideoTaskPath.MatchString(requestPath) || customVideoContentPath.MatchString(requestPath) || customArkVideoTaskPath.MatchString(requestPath)
|
||||
} else {
|
||||
allowed = allowed || customGeminiOperationPath.MatchString(requestPath)
|
||||
}
|
||||
@@ -72,19 +73,24 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont
|
||||
}
|
||||
return nil
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || mediaType != "application/json" {
|
||||
return errors.New("自定义渠道生成请求必须使用 application/json")
|
||||
}
|
||||
if method != http.MethodPost {
|
||||
return errors.New("自定义渠道不允许使用该请求方法")
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return errors.New("自定义渠道生成请求类型无效")
|
||||
}
|
||||
if apiFormat == "openai" {
|
||||
if len(query) != 0 || (!strings.HasSuffix(requestPath, "/responses") && !strings.HasSuffix(requestPath, "/chat/completions") && !strings.HasSuffix(requestPath, "/images/generations") && !strings.HasSuffix(requestPath, "/audio/speech") && !strings.HasSuffix(requestPath, "/video/generations") && !strings.HasSuffix(requestPath, "/videos/generations") && !strings.HasSuffix(requestPath, "/contents/generations/tasks")) {
|
||||
multipartAllowed := mediaType == "multipart/form-data" && (strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/videos"))
|
||||
jsonAllowed := mediaType == "application/json" && (strings.HasSuffix(requestPath, "/responses") || strings.HasSuffix(requestPath, "/chat/completions") || strings.HasSuffix(requestPath, "/images/generations") || strings.HasSuffix(requestPath, "/audio/speech") || strings.HasSuffix(requestPath, "/video/generations") || strings.HasSuffix(requestPath, "/videos/generations") || strings.HasSuffix(requestPath, "/videos") || strings.HasSuffix(requestPath, "/contents/generations/tasks"))
|
||||
if len(query) != 0 || (!multipartAllowed && !jsonAllowed) {
|
||||
return errors.New("自定义渠道不允许访问该上游接口")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if mediaType != "application/json" {
|
||||
return errors.New("Gemini 自定义渠道生成请求必须使用 application/json")
|
||||
}
|
||||
if !customGeminiRelayPath.MatchString(requestPath) {
|
||||
return errors.New("自定义渠道不允许访问该上游接口")
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) {
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/responses", apiFormat: "openai", contentType: "application/json"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/chat/completions", apiFormat: "openai", contentType: "application/json; charset=utf-8"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/audio/speech", apiFormat: "openai", contentType: "application/json"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/images/edits", apiFormat: "openai", contentType: "multipart/form-data; boundary=test"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/videos", apiFormat: "openai", contentType: "application/json"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/videos", apiFormat: "openai", contentType: "multipart/form-data; boundary=test"},
|
||||
{method: http.MethodGet, target: "https://api.example.com/v1/videos/task-1/content", apiFormat: "openai"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/video/generations", apiFormat: "openai", contentType: "application/json"},
|
||||
{method: http.MethodGet, target: "https://api.example.com/v1/video/generations/task-1", apiFormat: "openai"},
|
||||
{method: http.MethodPost, target: "https://api.x.ai/v1/videos/generations", apiFormat: "openai", contentType: "application/json"},
|
||||
@@ -62,6 +66,7 @@ func TestAuthorizeCustomRelayRejectsArbitraryRequestsAndCredentialQueries(t *tes
|
||||
{method: http.MethodGet, target: "https://api.example.com/account", apiFormat: "openai"},
|
||||
{method: http.MethodGet, target: "https://api.example.com/v1/models?api_key=secret", apiFormat: "openai"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/responses", apiFormat: "openai", contentType: "text/plain"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/account", apiFormat: "openai", contentType: "multipart/form-data; boundary=test"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/../account/chat/completions", apiFormat: "openai", contentType: "application/json"},
|
||||
{method: http.MethodPost, target: "https://api.example.com/v1/models/gemini:streamGenerateContent?alt=sse&token=secret", apiFormat: "gemini", contentType: "application/json"},
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ type ModelChannel struct {
|
||||
APIFormat string `json:"apiFormat" gorm:"size:24"`
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
|
||||
HeadersJSON string `json:"-" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
@@ -75,14 +75,15 @@ type AdminReferenceData struct {
|
||||
}
|
||||
|
||||
type ChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
ConcurrencyLimit *int `json:"concurrencyLimit"`
|
||||
UseGlobalConcurrency *bool `json:"useGlobalConcurrency"`
|
||||
Models []string `json:"models"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
ConcurrencyLimit *int `json:"concurrencyLimit"`
|
||||
UseGlobalConcurrency *bool `json:"useGlobalConcurrency"`
|
||||
Models []string `json:"models"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type PublicModelChannel struct {
|
||||
@@ -97,6 +98,7 @@ type PublicModelChannel struct {
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
Models []string `json:"models"`
|
||||
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
|
||||
Headers []OutboundHeader `json:"headers,omitempty"`
|
||||
HasAPIKey bool `json:"hasApiKey"`
|
||||
HasSecretKey bool `json:"hasSecretKey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
@@ -595,6 +597,10 @@ func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.M
|
||||
}
|
||||
models := uniqueNonEmpty(req.Models)
|
||||
modelsJSON, _ := json.Marshal(models)
|
||||
headersJSON, err := EncodeOutboundHeadersJSON(req.Headers)
|
||||
if err != nil {
|
||||
return channel, err
|
||||
}
|
||||
channel.Name = name
|
||||
channel.BaseURL = strings.TrimRight(baseURL, "/")
|
||||
if req.APIKey != "" {
|
||||
@@ -616,6 +622,7 @@ func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.M
|
||||
return channel, BadAuthRequest("请填写渠道最大并发数")
|
||||
}
|
||||
channel.ModelsJSON = string(modelsJSON)
|
||||
channel.HeadersJSON = headersJSON
|
||||
if req.Enabled != nil {
|
||||
channel.Enabled = *req.Enabled
|
||||
}
|
||||
@@ -632,6 +639,9 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel
|
||||
if req.Models == nil {
|
||||
req.Models = channelModelNames(channel)
|
||||
}
|
||||
if req.Headers == nil {
|
||||
req.Headers, _ = ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -661,11 +671,15 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
}
|
||||
apiKey := ""
|
||||
baseURL := channel.BaseURL
|
||||
var headers []OutboundHeader
|
||||
if channel.Scope == model.ChannelScopeSystem {
|
||||
if !admin {
|
||||
apiKey = "system"
|
||||
baseURL = "/api/ai/system/" + channel.ID
|
||||
}
|
||||
if admin {
|
||||
headers, _ = ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
}
|
||||
} else if admin {
|
||||
apiKey = channel.APIKey
|
||||
}
|
||||
@@ -681,6 +695,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
ConcurrencyLimit: channel.ConcurrencyLimit,
|
||||
Models: models,
|
||||
ModelCosts: modelCosts,
|
||||
Headers: headers,
|
||||
HasAPIKey: strings.TrimSpace(channel.APIKey) != "",
|
||||
HasSecretKey: strings.TrimSpace(channel.SecretKey) != "",
|
||||
CreatedAt: channel.CreatedAt,
|
||||
|
||||
@@ -38,16 +38,49 @@ func TestChannelFromRequestStoresConnectionWithoutDefaultProtocol(t *testing.T)
|
||||
func TestMergeChannelRequestSupportsEnabledOnlyPatch(t *testing.T) {
|
||||
enabled := false
|
||||
req := mergeChannelRequest(ChannelRequest{Enabled: &enabled}, model.ModelChannel{
|
||||
Name: "Video",
|
||||
BaseURL: "https://example.com/v1",
|
||||
APIFormat: "openai",
|
||||
ModelsJSON: `["custom-video"]`,
|
||||
Name: "Video",
|
||||
BaseURL: "https://example.com/v1",
|
||||
APIFormat: "openai",
|
||||
ModelsJSON: `["custom-video"]`,
|
||||
HeadersJSON: `[{"name":"User-Agent","value":"Stored Agent"}]`,
|
||||
})
|
||||
if req.Name != "Video" || req.BaseURL != "https://example.com/v1" || len(req.Models) != 1 {
|
||||
if req.Name != "Video" || req.BaseURL != "https://example.com/v1" || len(req.Models) != 1 || len(req.Headers) != 1 {
|
||||
t.Fatalf("mergeChannelRequest() = %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) {
|
||||
request := ChannelRequest{Name: "Headers", BaseURL: "https://example.com/v1", Headers: []OutboundHeader{{Name: "User-Agent", Value: "Custom Agent"}}}
|
||||
channel, err := channelFromRequest(request, model.ModelChannel{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if channel.HeadersJSON != `[{"name":"User-Agent","value":"Custom Agent"}]` {
|
||||
t.Fatalf("HeadersJSON = %q", channel.HeadersJSON)
|
||||
}
|
||||
|
||||
request.Headers = []OutboundHeader{}
|
||||
channel, err = channelFromRequest(request, channel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if channel.HeadersJSON != `[]` {
|
||||
t.Fatalf("cleared HeadersJSON = %q", channel.HeadersJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicChannelOnlyReturnsSystemHeadersToAdmin(t *testing.T) {
|
||||
channel := model.ModelChannel{ID: "system-1", Scope: model.ChannelScopeSystem, BaseURL: "https://example.com/v1", HeadersJSON: `[{"name":"X-Gateway-Tenant","value":"tenant-a"}]`}
|
||||
adminView := publicChannel(channel, true, nil)
|
||||
if len(adminView.Headers) != 1 || adminView.Headers[0].Name != "X-Gateway-Tenant" {
|
||||
t.Fatalf("admin headers = %#v", adminView.Headers)
|
||||
}
|
||||
userView := publicChannel(channel, false, nil)
|
||||
if len(userView.Headers) != 0 {
|
||||
t.Fatalf("user headers = %#v", userView.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelFromRequestRejectsInvalidConcurrencyLimit(t *testing.T) {
|
||||
for _, limit := range []int{0, 1000} {
|
||||
_, err := channelFromRequest(ChannelRequest{Name: "Bad", BaseURL: "https://example.com/v1", ConcurrencyLimit: &limit}, model.ModelChannel{})
|
||||
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
)
|
||||
|
||||
type ChannelModelsRequest struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
}
|
||||
|
||||
type channelModelsPayload struct {
|
||||
@@ -50,6 +51,10 @@ func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, inp
|
||||
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" {
|
||||
@@ -71,6 +76,7 @@ func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, inp
|
||||
} else {
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
ApplyOutboundHeaders(request, headers)
|
||||
|
||||
// 只代理固定的模型目录 GET;用户密钥仅用于本次请求,不写入数据库或日志。
|
||||
data, _, err := doBinary(request)
|
||||
|
||||
@@ -75,8 +75,12 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 使用服务端保存的渠道密钥请求上游,避免密钥为了拉目录再次经过浏览器。
|
||||
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat})
|
||||
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 使用服务端保存的渠道密钥和请求头访问上游,避免敏感配置再次经过浏览器。
|
||||
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,6 +178,10 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" {
|
||||
return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key")
|
||||
}
|
||||
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prompt := map[string]string{
|
||||
"text": "Reply with OK.",
|
||||
@@ -197,6 +205,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
BaseURL: channel.BaseURL,
|
||||
APIKey: channel.APIKey,
|
||||
SecretKey: channel.SecretKey,
|
||||
Headers: headers,
|
||||
Model: modelKey,
|
||||
Size: map[string]string{"image": "1024x1024", "video": "16:9"}[capability],
|
||||
Quality: "auto",
|
||||
|
||||
@@ -2,7 +2,10 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -14,9 +17,17 @@ import (
|
||||
|
||||
const (
|
||||
maxOutboundRedirects = 5
|
||||
maxOutboundHeaderCount = 32
|
||||
maxOutboundHeaderBytes = 16 << 10
|
||||
CustomRelayHeadersHeader = "X-Canvas-Upstream-Headers"
|
||||
DefaultOutboundUserAgent = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)"
|
||||
)
|
||||
|
||||
type OutboundHeader struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
var (
|
||||
outboundTransport = newOutboundTransport(resolveOutboundHost)
|
||||
customRelayTransport = newOutboundTransport(resolveCustomRelayHost)
|
||||
@@ -108,6 +119,128 @@ func ApplyDefaultOutboundHeaders(req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeOutboundHeaders 是自定义 Header 的安全边界:允许业务/WAF 校验头和 User-Agent,
|
||||
// 但禁止覆盖鉴权、请求体协议、代理内部头及 hop-by-hop 头。
|
||||
func NormalizeOutboundHeaders(headers []OutboundHeader) ([]OutboundHeader, error) {
|
||||
if len(headers) > maxOutboundHeaderCount {
|
||||
return nil, BadAuthRequest(fmt.Sprintf("自定义请求头最多支持 %d 项", maxOutboundHeaderCount))
|
||||
}
|
||||
seen := make(map[string]struct{}, len(headers))
|
||||
normalized := make([]OutboundHeader, 0, len(headers))
|
||||
totalBytes := 0
|
||||
for _, item := range headers {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if name == "" && value == "" {
|
||||
continue
|
||||
}
|
||||
if name == "" || !validOutboundHeaderName(name) {
|
||||
return nil, BadAuthRequest("自定义请求头名称无效,请使用标准 HTTP Header 名称")
|
||||
}
|
||||
name = http.CanonicalHeaderKey(name)
|
||||
lowerName := strings.ToLower(name)
|
||||
if blockedOutboundHeader(lowerName) {
|
||||
return nil, BadAuthRequest("请求头 " + name + " 由系统管理,不允许自定义")
|
||||
}
|
||||
if _, exists := seen[lowerName]; exists {
|
||||
return nil, BadAuthRequest("自定义请求头不能重复:" + name)
|
||||
}
|
||||
if !validOutboundHeaderValue(value) {
|
||||
return nil, BadAuthRequest("请求头 " + name + " 的值包含非法控制字符")
|
||||
}
|
||||
if len(name) > 128 || len(value) > 4096 {
|
||||
return nil, BadAuthRequest("单个自定义请求头名称或值过长")
|
||||
}
|
||||
totalBytes += len(name) + len(value)
|
||||
if totalBytes > maxOutboundHeaderBytes {
|
||||
return nil, BadAuthRequest("自定义请求头总大小不能超过 16KB")
|
||||
}
|
||||
seen[lowerName] = struct{}{}
|
||||
normalized = append(normalized, OutboundHeader{Name: name, Value: value})
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func ApplyOutboundHeaders(req *http.Request, headers []OutboundHeader) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
for _, item := range headers {
|
||||
req.Header.Set(item.Name, item.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func EncodeOutboundHeadersJSON(headers []OutboundHeader) (string, error) {
|
||||
normalized, err := NormalizeOutboundHeaders(headers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(normalized)
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
func ParseOutboundHeadersJSON(raw string) ([]OutboundHeader, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []OutboundHeader{}, nil
|
||||
}
|
||||
var headers []OutboundHeader
|
||||
if err := json.Unmarshal([]byte(raw), &headers); err != nil {
|
||||
return nil, errors.New("渠道自定义请求头配置损坏")
|
||||
}
|
||||
return NormalizeOutboundHeaders(headers)
|
||||
}
|
||||
|
||||
func DecodeRelayOutboundHeaders(encoded string) ([]OutboundHeader, error) {
|
||||
if strings.TrimSpace(encoded) == "" {
|
||||
return []OutboundHeader{}, nil
|
||||
}
|
||||
if len(encoded) > (maxOutboundHeaderBytes*2)+1024 {
|
||||
return nil, BadAuthRequest("自定义请求头配置过大")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, BadAuthRequest("自定义请求头编码无效")
|
||||
}
|
||||
var headers []OutboundHeader
|
||||
if err := json.Unmarshal(data, &headers); err != nil {
|
||||
return nil, BadAuthRequest("自定义请求头格式无效")
|
||||
}
|
||||
return NormalizeOutboundHeaders(headers)
|
||||
}
|
||||
|
||||
func validOutboundHeaderName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < len(name); index++ {
|
||||
char := name[index]
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(char)) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validOutboundHeaderValue(value string) bool {
|
||||
for index := 0; index < len(value); index++ {
|
||||
char := value[index]
|
||||
if char == '\t' || char >= 32 && char != 127 {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func blockedOutboundHeader(name string) bool {
|
||||
switch name {
|
||||
case "authorization", "proxy-authorization", "cookie", "set-cookie", "host", "content-length", "content-type", "accept", "connection", "proxy-connection", "keep-alive", "transfer-encoding", "te", "trailer", "upgrade", "forwarded", "x-goog-api-key":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(name, "x-canvas-") || strings.HasPrefix(name, "x-forwarded-")
|
||||
}
|
||||
|
||||
func newOutboundTransport(resolveHost func(context.Context, string) ([]net.IP, error)) *http.Transport {
|
||||
dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return &http.Transport{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -8,6 +9,46 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeOutboundHeadersAllowsCustomUserAgent(t *testing.T) {
|
||||
headers, err := NormalizeOutboundHeaders([]OutboundHeader{{Name: "user-agent", Value: "Custom Gateway/2.0"}, {Name: "X-Gateway-Tenant", Value: "tenant-a"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
ApplyOutboundHeaders(request, headers)
|
||||
ApplyDefaultOutboundHeaders(request)
|
||||
if got := request.Header.Get("User-Agent"); got != "Custom Gateway/2.0" {
|
||||
t.Fatalf("User-Agent = %q", got)
|
||||
}
|
||||
if got := request.Header.Get("X-Gateway-Tenant"); got != "tenant-a" {
|
||||
t.Fatalf("X-Gateway-Tenant = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOutboundHeadersRejectsUnsafeAndDuplicateValues(t *testing.T) {
|
||||
tests := [][]OutboundHeader{
|
||||
{{Name: "Authorization", Value: "Bearer attacker"}},
|
||||
{{Name: "X-Test", Value: "safe\r\ninjected: true"}},
|
||||
{{Name: "X-Test", Value: "one"}, {Name: "x-test", Value: "two"}},
|
||||
}
|
||||
for _, headers := range tests {
|
||||
if _, err := NormalizeOutboundHeaders(headers); err == nil {
|
||||
t.Fatalf("NormalizeOutboundHeaders(%#v) should fail", headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRelayOutboundHeaders(t *testing.T) {
|
||||
raw := base64.StdEncoding.EncodeToString([]byte(`[{"name":"User-Agent","value":"Relay Agent"}]`))
|
||||
headers, err := DecodeRelayOutboundHeaders(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(headers) != 1 || headers[0].Name != "User-Agent" || headers[0].Value != "Relay Agent" {
|
||||
t.Fatalf("DecodeRelayOutboundHeaders() = %#v", headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDefaultOutboundHeaders(t *testing.T) {
|
||||
request, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,26 +33,27 @@ type canvasGenerationInput struct {
|
||||
}
|
||||
|
||||
type providerConfig struct {
|
||||
ChannelID string `json:"channelId"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
InterfaceType string `json:"interfaceType"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Model string `json:"model"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
TransparentBackground string `json:"transparentBackground"`
|
||||
Count string `json:"count"`
|
||||
VideoSeconds string `json:"videoSeconds"`
|
||||
VQuality string `json:"vquality"`
|
||||
VideoGenerateAudio string `json:"videoGenerateAudio"`
|
||||
VideoWatermark string `json:"videoWatermark"`
|
||||
AudioVoice string `json:"audioVoice"`
|
||||
AudioFormat string `json:"audioFormat"`
|
||||
AudioSpeed string `json:"audioSpeed"`
|
||||
AudioInstructions string `json:"audioInstructions"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
ChannelID string `json:"channelId"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
InterfaceType string `json:"interfaceType"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
Model string `json:"model"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
TransparentBackground string `json:"transparentBackground"`
|
||||
Count string `json:"count"`
|
||||
VideoSeconds string `json:"videoSeconds"`
|
||||
VQuality string `json:"vquality"`
|
||||
VideoGenerateAudio string `json:"videoGenerateAudio"`
|
||||
VideoWatermark string `json:"videoWatermark"`
|
||||
AudioVoice string `json:"audioVoice"`
|
||||
AudioFormat string `json:"audioFormat"`
|
||||
AudioSpeed string `json:"audioSpeed"`
|
||||
AudioInstructions string `json:"audioInstructions"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
}
|
||||
|
||||
const providerHTTPTimeout = 5 * time.Minute
|
||||
@@ -278,6 +279,11 @@ func normalizedMediaMimeType(declared string, data []byte) string {
|
||||
}
|
||||
|
||||
func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig, error) {
|
||||
headers, err := NormalizeOutboundHeaders(config.Headers)
|
||||
if err != nil {
|
||||
return providerConfig{}, err
|
||||
}
|
||||
config.Headers = headers
|
||||
channelID := strings.TrimSpace(config.ChannelID)
|
||||
if channelID == "" {
|
||||
channelID = systemChannelIDFromBaseURL(config.BaseURL)
|
||||
@@ -319,6 +325,10 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
|
||||
config.BaseURL = channel.BaseURL
|
||||
config.APIKey = channel.APIKey
|
||||
config.SecretKey = channel.SecretKey
|
||||
config.Headers, err = ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
if err != nil {
|
||||
return providerConfig{}, err
|
||||
}
|
||||
config.Model = modelName
|
||||
return config, nil
|
||||
}
|
||||
@@ -861,6 +871,7 @@ func postGeminiJSON(ctx context.Context, config providerConfig, path string, bod
|
||||
}
|
||||
req.Header.Set("x-goog-api-key", config.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
return doJSON(req, target)
|
||||
}
|
||||
|
||||
@@ -870,6 +881,7 @@ func getGeminiJSON(ctx context.Context, config providerConfig, path string, targ
|
||||
return err
|
||||
}
|
||||
req.Header.Set("x-goog-api-key", config.APIKey)
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
return doJSON(req, target)
|
||||
}
|
||||
|
||||
@@ -879,6 +891,7 @@ func getGeminiBinary(ctx context.Context, config providerConfig, rawURL string)
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("x-goog-api-key", config.APIKey)
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
return doBinary(req)
|
||||
}
|
||||
|
||||
@@ -1466,6 +1479,7 @@ func postJSON(ctx context.Context, config providerConfig, path string, body inte
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
return doJSON(req, target)
|
||||
}
|
||||
|
||||
@@ -1476,6 +1490,7 @@ func postForm(ctx context.Context, config providerConfig, path string, contentTy
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
return doJSON(req, target)
|
||||
}
|
||||
|
||||
@@ -1485,6 +1500,7 @@ func getJSON(ctx context.Context, config providerConfig, path string, target int
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
return doJSON(req, target)
|
||||
}
|
||||
|
||||
@@ -1496,6 +1512,7 @@ func postBinary(ctx context.Context, config providerConfig, path string, body in
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
return doBinary(req)
|
||||
}
|
||||
|
||||
@@ -1505,6 +1522,7 @@ func getBinary(ctx context.Context, config providerConfig, path string) ([]byte,
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
return doBinary(req)
|
||||
}
|
||||
|
||||
|
||||
@@ -197,6 +197,7 @@ func postJiMengJSON(ctx context.Context, config providerConfig, action string, b
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
ApplyDefaultOutboundHeaders(req)
|
||||
credentials := base.Credentials{
|
||||
AccessKeyID: strings.TrimSpace(config.APIKey),
|
||||
|
||||
109
web/src/components/channel-headers-editor.tsx
Normal file
109
web/src/components/channel-headers-editor.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Button, Input, Tooltip } from "antd";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
import type { ChannelHeader } from "@/stores/use-config-store";
|
||||
|
||||
const DEFAULT_USER_AGENT = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)";
|
||||
const MAX_HEADER_COUNT = 32;
|
||||
const BLOCKED_HEADERS = new Set([
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"host",
|
||||
"content-length",
|
||||
"content-type",
|
||||
"accept",
|
||||
"connection",
|
||||
"proxy-connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"forwarded",
|
||||
"x-goog-api-key",
|
||||
]);
|
||||
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
||||
|
||||
type Props = {
|
||||
value?: ChannelHeader[];
|
||||
onChange?: (headers: ChannelHeader[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ChannelHeadersEditor({ value = [], onChange, disabled }: Props) {
|
||||
const headers = Array.isArray(value) ? value : [];
|
||||
const validation = validateChannelHeaders(headers);
|
||||
const hasUserAgent = headers.some((header) => header.name.trim().toLowerCase() === "user-agent");
|
||||
const updateHeader = (index: number, patch: Partial<ChannelHeader>) => {
|
||||
onChange?.(headers.map((header, itemIndex) => (itemIndex === index ? { ...header, ...patch } : header)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border border-border/70 bg-muted/[.1] p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">自定义请求头</div>
|
||||
<div className="mt-0.5 break-all text-xs leading-5 text-foreground/50">默认发送 {DEFAULT_USER_AGENT};添加 User-Agent 后会覆盖默认值。</div>
|
||||
</div>
|
||||
{!hasUserAgent ? (
|
||||
<Button size="small" disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "User-Agent", value: DEFAULT_USER_AGENT }])}>
|
||||
添加 User-Agent
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{headers.length ? (
|
||||
<div className="space-y-2">
|
||||
{headers.map((header, index) => {
|
||||
const inputProps = {
|
||||
"aria-label": `请求头 ${index + 1} 值`,
|
||||
disabled,
|
||||
value: header.value,
|
||||
placeholder: "Header 值",
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => updateHeader(index, { value: event.target.value }),
|
||||
};
|
||||
return (
|
||||
<div key={index} className="grid min-w-0 grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_32px] gap-2">
|
||||
<Input aria-label={`请求头 ${index + 1} 名称`} disabled={disabled} value={header.name} placeholder="Header 名称" onChange={(event) => updateHeader(index, { name: event.target.value })} />
|
||||
{/token|key|secret/i.test(header.name) ? <Input.Password {...inputProps} /> : <Input {...inputProps} />}
|
||||
<Tooltip title="删除请求头">
|
||||
<Button aria-label={`删除请求头 ${index + 1}`} className="size-8 p-0" disabled={disabled} icon={<Trash2 className="size-3.5" />} onClick={() => onChange?.(headers.filter((_, itemIndex) => itemIndex !== index))} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button size="small" type="dashed" icon={<Plus className="size-3.5" />} disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "", value: "" }])}>
|
||||
添加请求头
|
||||
</Button>
|
||||
<span className={`text-xs ${validation ? "text-destructive" : "text-foreground/45"}`}>{validation || `${headers.length}/${MAX_HEADER_COUNT}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function validateChannelHeaders(headers?: ChannelHeader[]) {
|
||||
if (!headers?.length) return "";
|
||||
if (headers.length > MAX_HEADER_COUNT) return `自定义请求头最多支持 ${MAX_HEADER_COUNT} 项`;
|
||||
const seen = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
for (const header of headers) {
|
||||
const name = header.name.trim();
|
||||
const value = header.value.trim();
|
||||
if (!name && !value) continue;
|
||||
if (!name || !HEADER_NAME_PATTERN.test(name)) return "请求头名称无效,请使用标准 HTTP Header 名称";
|
||||
const normalizedName = name.toLowerCase();
|
||||
if (BLOCKED_HEADERS.has(normalizedName) || normalizedName.startsWith("x-canvas-") || normalizedName.startsWith("x-forwarded-")) return `${name} 由系统管理,不允许自定义`;
|
||||
if (seen.has(normalizedName)) return `请求头不能重复:${name}`;
|
||||
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) return `${name} 的值包含非法控制字符`;
|
||||
if (name.length > 128 || value.length > 4096) return "单个请求头名称或值过长";
|
||||
totalBytes += new TextEncoder().encode(name + value).length;
|
||||
if (totalBytes > 16 * 1024) return "自定义请求头总大小不能超过 16KB";
|
||||
seen.add(normalizedName);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -5,16 +5,17 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { createAdminChannel, deleteAdminChannel, listAdminChannels, updateAdminChannel } from "@/services/api/auth";
|
||||
import { type ModelChannel } from "@/stores/use-config-store";
|
||||
import { type ChannelHeader, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { useAdminContext } from "../admin-context";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminRowActions, AdminTableEmpty, AdminTableSkeleton, configuredSecretText } from "../components/admin-ui";
|
||||
import { ChannelModelManager } from "../components/channel-model-manager";
|
||||
|
||||
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; secretKey?: string; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean };
|
||||
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; secretKey?: string; headers?: ChannelHeader[]; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean };
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
@@ -79,7 +80,7 @@ export default function ChannelsPage() {
|
||||
const openDrawer = (channel?: ModelChannel) => {
|
||||
setEditingChannel(channel || null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", secretKey: "", useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", secretKey: "", useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true });
|
||||
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", secretKey: "", headers: channel.headers || [], useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true });
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
@@ -94,13 +95,18 @@ export default function ChannelsPage() {
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
const headerError = validateChannelHeaders(values.headers);
|
||||
if (headerError) {
|
||||
message.error(headerError);
|
||||
return;
|
||||
}
|
||||
if (!editingChannel && !values.apiKey?.trim()) {
|
||||
message.error("请填写 API Key 或 Access Key");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", secretKey: values.secretKey?.trim() || "", useGlobalConcurrency: values.useGlobalConcurrency !== false, concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined, enabled: values.enabled !== false };
|
||||
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", secretKey: values.secretKey?.trim() || "", headers: values.headers || [], useGlobalConcurrency: values.useGlobalConcurrency !== false, concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined, enabled: values.enabled !== false };
|
||||
await (editingChannel ? updateAdminChannel(editingChannel.id, payload) : createAdminChannel(payload));
|
||||
await syncChannels();
|
||||
setDrawerOpen(false);
|
||||
@@ -164,6 +170,7 @@ export default function ChannelsPage() {
|
||||
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
|
||||
<Form.Item name="apiKey" label={editingChannel ? `API Key / Access Key(${configuredSecretText})` : "API Key / Access Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key 或 Access Key" }]} extra="OpenAI 兼容协议填写 API Key;即梦官方协议填写 IAM Access Key。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原凭证" : "API Key 或 Access Key"} /></Form.Item>
|
||||
<Form.Item name="secretKey" label={editingChannel ? `Secret Key(${channelSecretText(editingChannel)})` : "Secret Key(可选)"} extra="仅即梦官方等 AK/SK 签名协议需要;其他渠道留空。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原 Secret Key" : "IAM Secret Key"} /></Form.Item>
|
||||
<div className="mb-6"><Form.Item name="headers" noStyle><ChannelHeadersEditor /></Form.Item></div>
|
||||
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="concurrencyLimit" label="渠道最大并发数" extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。" rules={useGlobalConcurrency ? [] : [{ required: true, message: "请填写渠道最大并发数" }, { type: "number", min: 1, max: 999, message: "请输入 1-999 的整数" }]}><InputNumber className="w-full" min={1} max={999} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-999"} /></Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNavigate, useSearchParams } from "react-router";
|
||||
import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { MODEL_PROTOCOL_OPTIONS, modelProtocolLabel } from "@/lib/model-protocols";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
@@ -391,6 +392,9 @@ export default function SettingsPage() {
|
||||
onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="lg:col-span-12">
|
||||
<ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} />
|
||||
</div>
|
||||
</div>
|
||||
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
|
||||
</section>
|
||||
@@ -572,11 +576,11 @@ function channelConnectionError(channel: ModelChannel) {
|
||||
}
|
||||
|
||||
function channelConnectionSignature(channel: ModelChannel) {
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.apiFormat, channel.interfaceType || "auto"].join("\n");
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
|
||||
}
|
||||
|
||||
function channelValidationError(channel: ModelChannel) {
|
||||
return channelConnectionError(channel) || (!channel.models.length ? "请添加至少一个模型" : "");
|
||||
return channelConnectionError(channel) || validateChannelHeaders(channel.headers) || (!channel.models.length ? "请添加至少一个模型" : "");
|
||||
}
|
||||
|
||||
function isChannelReady(channel: ModelChannel) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig } from "@/stores/use-config-store";
|
||||
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig, type ChannelHeader } from "@/stores/use-config-store";
|
||||
|
||||
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">;
|
||||
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { headers?: ChannelHeader[] };
|
||||
|
||||
export type ChannelRequest = {
|
||||
url: string;
|
||||
@@ -16,13 +16,22 @@ export function channelRequest(config: RelayConfig, upstreamUrl: string, headers
|
||||
}
|
||||
|
||||
const normalizedUpstreamUrl = new URL(upstreamUrl).toString();
|
||||
normalizedHeaders.delete("X-Canvas-Upstream-Headers");
|
||||
normalizedHeaders.delete("x-goog-api-key");
|
||||
normalizedHeaders.set("Authorization", `Bearer ${config.apiKey}`);
|
||||
normalizedHeaders.set("X-Canvas-Upstream-URL", normalizedUpstreamUrl);
|
||||
normalizedHeaders.set("X-Canvas-Upstream-Format", config.apiFormat === "gemini" ? "gemini" : "openai");
|
||||
if (config.headers?.length) normalizedHeaders.set("X-Canvas-Upstream-Headers", encodeChannelHeaders(config.headers));
|
||||
return {
|
||||
url: resolveBackendApiUrl("/api/ai/custom"),
|
||||
headers: Object.fromEntries(normalizedHeaders.entries()),
|
||||
credentials: "include",
|
||||
};
|
||||
}
|
||||
|
||||
function encodeChannelHeaders(headers: ChannelHeader[]) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(headers));
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
@@ -755,13 +755,14 @@ async function requestGeminiImagesOnce(config: AiConfig, prompt: string, referen
|
||||
for (const image of references) {
|
||||
parts.push(toGeminiImagePart(await imageToDataUrl(image)));
|
||||
}
|
||||
const request = channelRequest(config, geminiApiUrl(config, "generateContent"), geminiHeaders(config));
|
||||
const response = await axios.post<GeminiPayload>(
|
||||
geminiApiUrl(config, "generateContent"),
|
||||
request.url,
|
||||
{
|
||||
...toGeminiBody(config, [{ role: "user", content: prompt }], { generationConfig: { responseModalities: ["TEXT", "IMAGE"] } }),
|
||||
contents: [{ role: "user", parts }],
|
||||
},
|
||||
{ headers: geminiHeaders(config), signal: options?.signal },
|
||||
{ headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal },
|
||||
);
|
||||
return parseGeminiImagePayload(response.data);
|
||||
}
|
||||
@@ -893,7 +894,8 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
if (mask) formData.set("mask", dataUrlToFile(mask));
|
||||
|
||||
try {
|
||||
const response = await axios.post<ImageApiResponse>(aiApiUrl(requestConfig, "/images/edits"), formData, { headers: aiHeaders(requestConfig), signal: options?.signal });
|
||||
const request = channelRequest(requestConfig, aiApiUrl(requestConfig, "/images/edits"), aiHeaders(requestConfig));
|
||||
const response = await axios.post<ImageApiResponse>(request.url, formData, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal });
|
||||
const images = parseImagePayload(response.data);
|
||||
return images;
|
||||
} catch (error) {
|
||||
@@ -990,6 +992,7 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal
|
||||
baseUrl: channel.baseUrl,
|
||||
apiKey: channel.apiKey,
|
||||
apiFormat: channel.apiFormat,
|
||||
headers: channel.headers,
|
||||
},
|
||||
{ withCredentials: true },
|
||||
);
|
||||
|
||||
@@ -204,7 +204,12 @@ function geminiVeoHeaders(config: ResolvedAiConfig, contentType?: string) {
|
||||
}
|
||||
|
||||
async function channelPost<T>(config: ResolvedAiConfig, upstreamUrl: string, body: unknown, options?: RequestOptions) {
|
||||
const request = channelRequest(config, upstreamUrl, { "Content-Type": "application/json" });
|
||||
const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json"));
|
||||
return (await axios.post<T>(request.url, body, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data;
|
||||
}
|
||||
|
||||
async function channelPostForm<T>(config: ResolvedAiConfig, upstreamUrl: string, body: FormData, options?: RequestOptions) {
|
||||
const request = channelRequest(config, upstreamUrl, aiHeaders(config));
|
||||
return (await axios.post<T>(request.url, body, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data;
|
||||
}
|
||||
|
||||
@@ -213,6 +218,11 @@ async function channelGet<T>(config: ResolvedAiConfig, upstreamUrl: string, opti
|
||||
return (await axios.get<T>(request.url, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data;
|
||||
}
|
||||
|
||||
async function channelGetBlob(config: ResolvedAiConfig, upstreamUrl: string, options?: RequestOptions) {
|
||||
const request = channelRequest(config, upstreamUrl);
|
||||
return (await axios.get<Blob>(request.url, { headers: request.headers, withCredentials: request.credentials === "include", responseType: "blob", signal: options?.signal })).data;
|
||||
}
|
||||
|
||||
function findGeminiVideoURL(value: unknown): string {
|
||||
if (!value || typeof value !== "object") return "";
|
||||
if (Array.isArray(value)) {
|
||||
@@ -259,7 +269,7 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
|
||||
};
|
||||
try {
|
||||
const createPath = config.interfaceType === "xai-video" ? "/videos/generations" : "/videos";
|
||||
const created = unwrapVideoResponse(config.interfaceType === "xai-video" ? await channelPost<ApiVideoResponse>(config, aiApiUrl(config, createPath), payload, options) : (await axios.post<ApiVideoResponse>(aiApiUrl(config, createPath), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data);
|
||||
const created = unwrapVideoResponse(await channelPost<ApiVideoResponse>(config, aiApiUrl(config, createPath), payload, options));
|
||||
const id = videoTaskId(created);
|
||||
if (!id) throw new Error("视频接口没有返回任务 ID");
|
||||
return { id, provider: "openai", model };
|
||||
@@ -277,7 +287,7 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
|
||||
const files = await Promise.all(references.slice(0, 7).map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
|
||||
files.forEach((file) => body.append("input_reference[]", file));
|
||||
try {
|
||||
const created = unwrapVideoResponse((await axios.post<ApiVideoResponse>(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config), signal: options?.signal })).data);
|
||||
const created = unwrapVideoResponse(await channelPostForm<ApiVideoResponse>(config, aiApiUrl(config, "/videos"), body, options));
|
||||
if (!created.id) throw new Error("视频接口没有返回任务 ID");
|
||||
return { id: created.id, provider: "openai", model };
|
||||
} catch (error) {
|
||||
@@ -287,13 +297,13 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
|
||||
|
||||
async function pollOpenAIVideoTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const video = unwrapVideoResponse(config.interfaceType === "xai-video" ? await channelGet<ApiVideoResponse>(config, aiApiUrl(config, `/videos/${task.id}`), options) : (await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), signal: options?.signal })).data);
|
||||
const video = unwrapVideoResponse(await channelGet<ApiVideoResponse>(config, aiApiUrl(config, `/videos/${task.id}`), options));
|
||||
if (video.status === "completed" || video.status === "succeeded" || video.status === "success" || video.status === "done") {
|
||||
const resultUrl = video.video?.url || video.video_url || video.result_url;
|
||||
if (resultUrl) return { status: "completed", result: await videoResultFromUrl(resultUrl, options) };
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob", signal: options?.signal });
|
||||
await assertVideoBlob(content.data);
|
||||
return { status: "completed", result: { blob: content.data } };
|
||||
const content = await channelGetBlob(config, aiApiUrl(config, `/videos/${task.id}/content`), options);
|
||||
await assertVideoBlob(content);
|
||||
return { status: "completed", result: { blob: content } };
|
||||
}
|
||||
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" };
|
||||
return { status: "pending" };
|
||||
@@ -312,9 +322,7 @@ async function createSeedanceTask(config: ResolvedAiConfig, model: string, promp
|
||||
: await buildSeedanceVideosPayload(config, model, prompt, references, videoReferences, audioReferences);
|
||||
|
||||
try {
|
||||
const raw = isVolcengineArk
|
||||
? await channelPost<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config), payload, options)
|
||||
: (await axios.post<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data;
|
||||
const raw = await channelPost<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config), payload, options);
|
||||
const created = unwrapSeedanceTask(raw);
|
||||
const id = created.id || created.task_id;
|
||||
if (!id) throw new Error("Seedance 接口没有返回任务 ID");
|
||||
@@ -326,18 +334,15 @@ async function createSeedanceTask(config: ResolvedAiConfig, model: string, promp
|
||||
|
||||
async function pollSeedanceTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const raw =
|
||||
config.interfaceType === "volcengine-ark-video"
|
||||
? await channelGet<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config, task.id), options)
|
||||
: (await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), signal: options?.signal })).data;
|
||||
const raw = await channelGet<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config, task.id), options);
|
||||
const state = unwrapSeedanceTask(raw);
|
||||
if (state.status === "succeeded" || state.status === "completed") {
|
||||
const url = state.video_url || state.content?.video_url;
|
||||
if (url) return { status: "completed", result: await videoResultFromUrl(url, options) };
|
||||
if (isArkPlanBaseUrl(config.baseUrl)) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob", signal: options?.signal });
|
||||
await assertVideoBlob(content.data);
|
||||
return { status: "completed", result: { blob: content.data } };
|
||||
const content = await channelGetBlob(config, aiApiUrl(config, `/videos/${task.id}/content`), options);
|
||||
await assertVideoBlob(content);
|
||||
return { status: "completed", result: { blob: content } };
|
||||
}
|
||||
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: seedanceErrorMessage(state) || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
|
||||
return { status: "pending" };
|
||||
|
||||
@@ -9,6 +9,7 @@ import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-ge
|
||||
|
||||
export type ApiCallFormat = "openai" | "gemini";
|
||||
export type ChannelInterfaceType = ModelProtocol;
|
||||
export type ChannelHeader = { name: string; value: string };
|
||||
|
||||
export type ModelChannel = {
|
||||
id: string;
|
||||
@@ -16,6 +17,7 @@ export type ModelChannel = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
secretKey?: string;
|
||||
headers?: ChannelHeader[];
|
||||
apiFormat: ApiCallFormat;
|
||||
interfaceType?: ChannelInterfaceType;
|
||||
models: string[];
|
||||
@@ -298,6 +300,7 @@ export function createModelChannel(channel?: Partial<ModelChannel>): ModelChanne
|
||||
baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)),
|
||||
apiKey: channel?.apiKey || "",
|
||||
secretKey: channel?.secretKey || "",
|
||||
headers: Array.isArray(channel?.headers) ? channel.headers.map((header) => ({ name: String(header.name || ""), value: String(header.value || "") })) : [],
|
||||
apiFormat,
|
||||
interfaceType,
|
||||
models: uniqueRawModels(channel?.models || []),
|
||||
@@ -388,6 +391,7 @@ export function resolveModelRequestConfig(config: AiConfig, value: string) {
|
||||
baseUrl: channel.baseUrl,
|
||||
apiKey: channel.apiKey,
|
||||
secretKey: channel.secretKey,
|
||||
headers: channel.headers,
|
||||
apiFormat: interfaceType ? (interfaceType === "gemini-veo" ? "gemini" as const : "openai" as const) : channel.apiFormat,
|
||||
interfaceType,
|
||||
channelId: channel.scope === "system" ? channel.id : "",
|
||||
|
||||
Reference in New Issue
Block a user