diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 406f0c721..805673f7f 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -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) diff --git a/backend/internal/handler/custom_proxy.go b/backend/internal/handler/custom_proxy.go index 5523caeae..3dcfe6221 100644 --- a/backend/internal/handler/custom_proxy.go +++ b/backend/internal/handler/custom_proxy.go @@ -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 diff --git a/backend/internal/handler/custom_proxy_test.go b/backend/internal/handler/custom_proxy_test.go index 1573dcfcc..abcc008d0 100644 --- a/backend/internal/handler/custom_proxy_test.go +++ b/backend/internal/handler/custom_proxy_test.go @@ -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" diff --git a/backend/internal/handler/security.go b/backend/internal/handler/security.go index ecb0d0c23..056d75f83 100644 --- a/backend/internal/handler/security.go +++ b/backend/internal/handler/security.go @@ -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("自定义渠道不允许访问该上游接口") } diff --git a/backend/internal/handler/security_test.go b/backend/internal/handler/security_test.go index 3e97ad347..89adb5cdc 100644 --- a/backend/internal/handler/security_test.go +++ b/backend/internal/handler/security_test.go @@ -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"}, } diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index a34c9f5e4..923c6b6f6 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -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"` diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 6fda28c46..ced6e9f81 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -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, diff --git a/backend/internal/service/admin_channel_test.go b/backend/internal/service/admin_channel_test.go index bc3b434c6..d8fa0869b 100644 --- a/backend/internal/service/admin_channel_test.go +++ b/backend/internal/service/admin_channel_test.go @@ -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{}) diff --git a/backend/internal/service/channel_model_catalog.go b/backend/internal/service/channel_model_catalog.go index a465e8c4e..d31ba3d7b 100644 --- a/backend/internal/service/channel_model_catalog.go +++ b/backend/internal/service/channel_model_catalog.go @@ -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) diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 671c3ec76..1c1448c1c 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -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", diff --git a/backend/internal/service/outbound.go b/backend/internal/service/outbound.go index b6f5846ac..bbb8afe1d 100644 --- a/backend/internal/service/outbound.go +++ b/backend/internal/service/outbound.go @@ -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{ diff --git a/backend/internal/service/outbound_test.go b/backend/internal/service/outbound_test.go index 8c3d5de0f..a2db34ed2 100644 --- a/backend/internal/service/outbound_test.go +++ b/backend/internal/service/outbound_test.go @@ -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 { diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 8a86ccf6f..98b157bbc 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -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) } diff --git a/backend/internal/service/provider_jimeng.go b/backend/internal/service/provider_jimeng.go index 613d1da04..71a3c2790 100644 --- a/backend/internal/service/provider_jimeng.go +++ b/backend/internal/service/provider_jimeng.go @@ -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), diff --git a/web/src/components/channel-headers-editor.tsx b/web/src/components/channel-headers-editor.tsx new file mode 100644 index 000000000..810ba0f5f --- /dev/null +++ b/web/src/components/channel-headers-editor.tsx @@ -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) => { + onChange?.(headers.map((header, itemIndex) => (itemIndex === index ? { ...header, ...patch } : header))); + }; + + return ( +
+
+
+
自定义请求头
+
默认发送 {DEFAULT_USER_AGENT};添加 User-Agent 后会覆盖默认值。
+
+ {!hasUserAgent ? ( + + ) : null} +
+ {headers.length ? ( +
+ {headers.map((header, index) => { + const inputProps = { + "aria-label": `请求头 ${index + 1} 值`, + disabled, + value: header.value, + placeholder: "Header 值", + onChange: (event: ChangeEvent) => updateHeader(index, { value: event.target.value }), + }; + return ( +
+ updateHeader(index, { name: event.target.value })} /> + {/token|key|secret/i.test(header.name) ? : } + +
+ ); + })} +
+ ) : null} +
+ + {validation || `${headers.length}/${MAX_HEADER_COUNT}`} +
+
+ ); +} + +export function validateChannelHeaders(headers?: ChannelHeader[]) { + if (!headers?.length) return ""; + if (headers.length > MAX_HEADER_COUNT) return `自定义请求头最多支持 ${MAX_HEADER_COUNT} 项`; + const seen = new Set(); + 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 ""; +} diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index 770697cfe..ee124c60b 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -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() { +
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 09be5005f..dba7ecc68 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -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) })} /> +
+ updateChannel(channel.id, { headers })} /> +
updateChannel(channel.id, { modelCosts })} /> @@ -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) { diff --git a/web/src/services/api/custom-channel-relay.ts b/web/src/services/api/custom-channel-relay.ts index e57de0018..c60608934 100644 --- a/web/src/services/api/custom-channel-relay.ts +++ b/web/src/services/api/custom-channel-relay.ts @@ -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; +type RelayConfig = Pick & { 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); +} diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index e7af206d7..10c9a97eb 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -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( - 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(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(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 }, ); diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index 640c81ee7..11d77a9fc 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -204,7 +204,12 @@ function geminiVeoHeaders(config: ResolvedAiConfig, contentType?: string) { } async function channelPost(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(request.url, body, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data; +} + +async function channelPostForm(config: ResolvedAiConfig, upstreamUrl: string, body: FormData, options?: RequestOptions) { + const request = channelRequest(config, upstreamUrl, aiHeaders(config)); return (await axios.post(request.url, body, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data; } @@ -213,6 +218,11 @@ async function channelGet(config: ResolvedAiConfig, upstreamUrl: string, opti return (await axios.get(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(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(config, aiApiUrl(config, createPath), payload, options) : (await axios.post(aiApiUrl(config, createPath), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data); + const created = unwrapVideoResponse(await channelPost(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(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config), signal: options?.signal })).data); + const created = unwrapVideoResponse(await channelPostForm(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 { try { - const video = unwrapVideoResponse(config.interfaceType === "xai-video" ? await channelGet(config, aiApiUrl(config, `/videos/${task.id}`), options) : (await axios.get(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), signal: options?.signal })).data); + const video = unwrapVideoResponse(await channelGet(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(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>(config, seedanceApiUrl(config), payload, options) - : (await axios.post>(seedanceApiUrl(config), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data; + const raw = await channelPost>(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 { try { - const raw = - config.interfaceType === "volcengine-ark-video" - ? await channelGet>(config, seedanceApiUrl(config, task.id), options) - : (await axios.get>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), signal: options?.signal })).data; + const raw = await channelGet>(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(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" }; diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 02e5223f6..cea7095b3 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -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): 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 : "",