fix(api): 渠道请求头 - 补充自定义 Header 配置并应用到上游请求

Refs #39
This commit is contained in:
ddCat
2026-07-29 20:57:24 +08:00
parent 412399a473
commit 6de81049bf
21 changed files with 535 additions and 75 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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("自定义渠道不允许访问该上游接口")
}

View File

@@ -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"},
}

View File

@@ -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"`

View File

@@ -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,

View File

@@ -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{})

View File

@@ -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)

View File

@@ -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",

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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),

View 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 "";
}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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 },
);

View File

@@ -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" };

View File

@@ -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 : "",