Merge pull request #14 from CyrusAuyeung/agent/proxy-custom-channels
fix(api): 自定义渠道 - 移除浏览器 CORS 依赖
This commit is contained in:
@@ -72,6 +72,7 @@ func main() {
|
||||
handler.RegisterAnnouncementRoutes(api, svc)
|
||||
handler.RegisterFinanceRoutes(api, svc)
|
||||
handler.RegisterSystemProxyRoutes(api, svc)
|
||||
handler.RegisterCustomRelayRoutes(api, svc)
|
||||
handler.RegisterTaskRoutes(api, svc)
|
||||
handler.RegisterSessionRoutes(api, svc)
|
||||
handler.RegisterSkillRoutes(api, svc)
|
||||
@@ -117,7 +118,7 @@ func cors() gin.HandlerFunc {
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
|
||||
}
|
||||
c.Header("Access-Control-Allow-Headers", "Accept, Content-Type, Authorization, X-Requested-With, X-Canvas-Scene, X-Idempotency-Key")
|
||||
c.Header("Access-Control-Allow-Headers", "Accept, Content-Type, Authorization, X-Requested-With, X-Canvas-Scene, X-Idempotency-Key, X-Canvas-Upstream-URL, X-Canvas-Upstream-Format")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
|
||||
287
backend/internal/handler/custom_proxy.go
Normal file
287
backend/internal/handler/custom_proxy.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCustomRelayBodyBytes int64 = 32 << 20
|
||||
maxCustomRelayModelResponseBytes int64 = 4 << 20
|
||||
maxCustomRelayJSONResponseBytes int64 = 16 << 20
|
||||
maxCustomRelayErrorResponseBytes int64 = 64 << 10
|
||||
maxCustomRelayStreamBytes int64 = 32 << 20
|
||||
maxCustomRelayConcurrency = 4
|
||||
customRelayHTTPTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
customRelayClient = service.CustomRelayHTTPClient
|
||||
customRelayActive = struct {
|
||||
sync.Mutex
|
||||
users map[string]int
|
||||
}{users: map[string]int{}}
|
||||
)
|
||||
|
||||
func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.Any("/ai/custom", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "custom-relay:"+user.ID, 120, time.Minute) {
|
||||
return
|
||||
}
|
||||
if !acquireCustomRelaySlot(user.ID) {
|
||||
fail(c, http.StatusTooManyRequests, errors.New("自定义渠道并发请求过多,请等待已有请求完成"))
|
||||
return
|
||||
}
|
||||
defer releaseCustomRelaySlot(user.ID)
|
||||
proxyCustomRelayRequest(c)
|
||||
})
|
||||
}
|
||||
|
||||
func proxyCustomRelayRequest(c *gin.Context) {
|
||||
target, err := service.ValidateCustomRelayURL(c.GetHeader("X-Canvas-Upstream-URL"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
apiFormat := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Canvas-Upstream-Format")))
|
||||
if apiFormat == "" {
|
||||
apiFormat = "openai"
|
||||
}
|
||||
if err := authorizeCustomRelay(c.Request.Method, target, apiFormat, c.GetHeader("Content-Type")); err != nil {
|
||||
fail(c, http.StatusForbidden, err)
|
||||
return
|
||||
}
|
||||
apiKey, err := customRelayAPIKey(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
if c.Request.ContentLength > maxCustomRelayBodyBytes {
|
||||
fail(c, http.StatusRequestEntityTooLarge, errors.New("自定义渠道请求不能超过 32MB"))
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxCustomRelayBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
var maxBytesError *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesError) {
|
||||
fail(c, http.StatusRequestEntityTooLarge, errors.New("自定义渠道请求不能超过 32MB"))
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusBadRequest, errors.New("读取自定义渠道请求失败"))
|
||||
return
|
||||
}
|
||||
if c.Request.Method == http.MethodGet && len(body) != 0 {
|
||||
fail(c, http.StatusBadRequest, errors.New("模型列表请求不允许携带请求体"))
|
||||
return
|
||||
}
|
||||
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, errors.New("构造自定义渠道请求失败"))
|
||||
return
|
||||
}
|
||||
if contentType := c.GetHeader("Content-Type"); contentType != "" {
|
||||
upstreamReq.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(c.GetHeader("Accept")), "text/event-stream") {
|
||||
upstreamReq.Header.Set("Accept", "text/event-stream")
|
||||
} else {
|
||||
upstreamReq.Header.Set("Accept", "application/json")
|
||||
}
|
||||
upstreamReq.Header.Set("User-Agent", "InfiniteCanvas/custom-channel-relay")
|
||||
if apiFormat == "gemini" {
|
||||
upstreamReq.Header.Set("x-goog-api-key", apiKey)
|
||||
} else {
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := customRelayClient(customRelayHTTPTimeout).Do(upstreamReq)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游连接失败"))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
writeCustomRelayResponse(c, resp, apiKey, c.Request.Method == http.MethodGet)
|
||||
}
|
||||
|
||||
func writeCustomRelayResponse(c *gin.Context, resp *http.Response, apiKey string, modelList bool) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
writeCustomRelayError(c, resp, apiKey, mediaType)
|
||||
return
|
||||
}
|
||||
if mediaType == "text/event-stream" {
|
||||
c.Header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Status(resp.StatusCode)
|
||||
c.Writer.WriteHeaderNow()
|
||||
copyCustomRelayStream(c, resp.Body, apiKey)
|
||||
return
|
||||
}
|
||||
if mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json") {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游返回了不支持的内容类型"))
|
||||
return
|
||||
}
|
||||
limit := maxCustomRelayJSONResponseBytes
|
||||
if modelList {
|
||||
limit = maxCustomRelayModelResponseBytes
|
||||
}
|
||||
body, err := readLimitedRelayBody(resp.Body, limit)
|
||||
if err != nil || !json.Valid(body) {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游返回无效或过大的 JSON"))
|
||||
return
|
||||
}
|
||||
body = redactRelaySecret(body, apiKey)
|
||||
c.Data(resp.StatusCode, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func writeCustomRelayError(c *gin.Context, resp *http.Response, apiKey string, mediaType string) {
|
||||
body, err := readLimitedRelayBody(resp.Body, maxCustomRelayErrorResponseBytes)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游请求失败"))
|
||||
return
|
||||
}
|
||||
body = redactRelaySecret(body, apiKey)
|
||||
if (mediaType == "application/json" || strings.HasSuffix(mediaType, "+json")) && json.Valid(body) {
|
||||
c.Data(resp.StatusCode, "application/json; charset=utf-8", body)
|
||||
return
|
||||
}
|
||||
fail(c, resp.StatusCode, errors.New("自定义渠道上游请求失败"))
|
||||
}
|
||||
|
||||
func copyCustomRelayStream(c *gin.Context, source io.Reader, apiKey string) {
|
||||
redactor := newRelayStreamRedactor(apiKey)
|
||||
buffer := make([]byte, 32<<10)
|
||||
var written int64
|
||||
for written < maxCustomRelayStreamBytes {
|
||||
read, err := source.Read(buffer)
|
||||
if read > 0 {
|
||||
remaining := maxCustomRelayStreamBytes - written
|
||||
if int64(read) > remaining {
|
||||
read = int(remaining)
|
||||
}
|
||||
chunk := redactor.Push(buffer[:read], false)
|
||||
if len(chunk) > 0 {
|
||||
if _, writeErr := c.Writer.Write(chunk); writeErr != nil {
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
written += int64(read)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if tail := redactor.Push(nil, true); len(tail) > 0 {
|
||||
_, _ = c.Writer.Write(tail)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func readLimitedRelayBody(body io.Reader, limit int64) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(body, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, errors.New("response body is too large")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func customRelayAPIKey(value string) (string, error) {
|
||||
scheme, apiKey, found := strings.Cut(strings.TrimSpace(value), " ")
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if !found || !strings.EqualFold(scheme, "Bearer") || apiKey == "" || len(apiKey) > 512 || strings.ContainsAny(apiKey, "\r\n") {
|
||||
return "", errors.New("自定义渠道 API Key 无效")
|
||||
}
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
func acquireCustomRelaySlot(userID string) bool {
|
||||
customRelayActive.Lock()
|
||||
defer customRelayActive.Unlock()
|
||||
if customRelayActive.users[userID] >= maxCustomRelayConcurrency {
|
||||
return false
|
||||
}
|
||||
customRelayActive.users[userID]++
|
||||
return true
|
||||
}
|
||||
|
||||
func releaseCustomRelaySlot(userID string) {
|
||||
customRelayActive.Lock()
|
||||
defer customRelayActive.Unlock()
|
||||
if customRelayActive.users[userID] <= 1 {
|
||||
delete(customRelayActive.users, userID)
|
||||
return
|
||||
}
|
||||
customRelayActive.users[userID]--
|
||||
}
|
||||
|
||||
func redactRelaySecret(body []byte, apiKey string) []byte {
|
||||
if apiKey == "" {
|
||||
return body
|
||||
}
|
||||
return bytes.ReplaceAll(body, []byte(apiKey), []byte("[REDACTED]"))
|
||||
}
|
||||
|
||||
type relayStreamRedactor struct {
|
||||
secret []byte
|
||||
pending []byte
|
||||
}
|
||||
|
||||
func newRelayStreamRedactor(secret string) *relayStreamRedactor {
|
||||
return &relayStreamRedactor{secret: []byte(secret)}
|
||||
}
|
||||
|
||||
func (r *relayStreamRedactor) Push(chunk []byte, final bool) []byte {
|
||||
r.pending = append(r.pending, chunk...)
|
||||
if len(r.secret) == 0 {
|
||||
result := append([]byte(nil), r.pending...)
|
||||
r.pending = r.pending[:0]
|
||||
return result
|
||||
}
|
||||
r.pending = bytes.ReplaceAll(r.pending, r.secret, []byte("[REDACTED]"))
|
||||
if final {
|
||||
result := append([]byte(nil), r.pending...)
|
||||
r.pending = r.pending[:0]
|
||||
return result
|
||||
}
|
||||
keep := relaySecretPrefixSuffixLength(r.pending, r.secret)
|
||||
cut := len(r.pending) - keep
|
||||
result := append([]byte(nil), r.pending[:cut]...)
|
||||
r.pending = append(r.pending[:0], r.pending[cut:]...)
|
||||
return result
|
||||
}
|
||||
|
||||
func relaySecretPrefixSuffixLength(data []byte, secret []byte) int {
|
||||
limit := len(secret) - 1
|
||||
if len(data) < limit {
|
||||
limit = len(data)
|
||||
}
|
||||
for length := limit; length > 0; length-- {
|
||||
if bytes.Equal(data[len(data)-length:], secret[:length]) {
|
||||
return length
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
202
backend/internal/handler/custom_proxy_test.go
Normal file
202
backend/internal/handler/custom_proxy_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const apiKey = "relay-secret-key"
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+apiKey {
|
||||
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Set-Cookie", "upstream=unsafe")
|
||||
_, _ = io.WriteString(w, `{"data":[]}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
useCustomRelayTestClient(t, upstream.Client())
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
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 "+apiKey)
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/models")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
request.Header.Set("Cookie", "browser=session")
|
||||
request.Header.Set("Origin", "https://canvas.example.com")
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if response.Header().Get("Set-Cookie") != "" {
|
||||
t.Fatal("upstream Set-Cookie should not be forwarded")
|
||||
}
|
||||
if strings.Contains(response.Body.String(), apiKey) {
|
||||
t.Fatal("response leaked API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRelayConvertsGeminiAuthentication(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const apiKey = "gemini-secret-key"
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-goog-api-key") != apiKey {
|
||||
t.Errorf("x-goog-api-key = %q", r.Header.Get("x-goog-api-key"))
|
||||
}
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Errorf("Authorization should not be forwarded, got %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"candidates":[]}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
useCustomRelayTestClient(t, upstream.Client())
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/ai/custom", strings.NewReader(`{"contents":[]}`))
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1beta/models/gemini-test:generateContent")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "gemini")
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const apiKey = "stream-secret"
|
||||
release := make(chan struct{})
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, "data: first\n\n")
|
||||
w.(http.Flusher).Flush()
|
||||
<-release
|
||||
_, _ = io.WriteString(w, "data: second\n\n")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
useCustomRelayTestClient(t, upstream.Client())
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
context, _ := gin.CreateTestContext(w)
|
||||
context.Request = r
|
||||
proxyCustomRelayRequest(context)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
request, _ := http.NewRequest(http.MethodPost, proxy.URL, strings.NewReader(`{"model":"test"}`))
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "text/event-stream")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/responses")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
close(release)
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := bufio.NewReader(response.Body)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
close(release)
|
||||
_ = response.Body.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if line != "data: first\n" {
|
||||
close(release)
|
||||
_ = response.Body.Close()
|
||||
t.Fatalf("first streamed line = %q", line)
|
||||
}
|
||||
close(release)
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
|
||||
func TestRelayStreamRedactorHandlesSplitSecret(t *testing.T) {
|
||||
redactor := newRelayStreamRedactor("split-secret")
|
||||
output := append(redactor.Push([]byte("before split-"), false), redactor.Push([]byte("secret after"), true)...)
|
||||
if bytes.Contains(output, []byte("split-secret")) || !bytes.Contains(output, []byte("[REDACTED]")) {
|
||||
t.Fatalf("redacted output = %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
connected := false
|
||||
previous := customRelayClient
|
||||
customRelayClient = func(time.Duration) *http.Client {
|
||||
connected = true
|
||||
return http.DefaultClient
|
||||
}
|
||||
t.Cleanup(func() { customRelayClient = previous })
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/ai/custom", strings.NewReader(`{"model":"test"}`))
|
||||
request.ContentLength = maxCustomRelayBodyBytes + 1
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", "https://127.0.0.1/v1/responses")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context)
|
||||
if response.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if connected {
|
||||
t.Fatal("oversized request should not create an upstream client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRelayConcurrencyLimitReleasesSlots(t *testing.T) {
|
||||
const userID = "relay-limit-test-user"
|
||||
for index := 0; index < maxCustomRelayConcurrency; index++ {
|
||||
if !acquireCustomRelaySlot(userID) {
|
||||
t.Fatalf("slot %d should be available", index)
|
||||
}
|
||||
}
|
||||
if acquireCustomRelaySlot(userID) {
|
||||
t.Fatal("request above the concurrency limit should be rejected")
|
||||
}
|
||||
for index := 0; index < maxCustomRelayConcurrency; index++ {
|
||||
releaseCustomRelaySlot(userID)
|
||||
}
|
||||
if !acquireCustomRelaySlot(userID) {
|
||||
t.Fatal("released slot should be reusable")
|
||||
}
|
||||
releaseCustomRelaySlot(userID)
|
||||
}
|
||||
|
||||
func useCustomRelayTestClient(t *testing.T, client *http.Client) {
|
||||
t.Helper()
|
||||
previous := customRelayClient
|
||||
customRelayClient = func(time.Duration) *http.Client { return client }
|
||||
t.Cleanup(func() { customRelayClient = previous })
|
||||
}
|
||||
@@ -23,9 +23,10 @@ import (
|
||||
const maxSystemProxyBodyBytes int64 = 64 << 20
|
||||
|
||||
var (
|
||||
runtimeService *service.Service
|
||||
geminiGeneratePath = regexp.MustCompile(`^/models/([^/:]+):(generateContent|streamGenerateContent)$`)
|
||||
openAIPostEndpoints = map[string]bool{
|
||||
runtimeService *service.Service
|
||||
geminiGeneratePath = regexp.MustCompile(`^/models/([^/:]+):(generateContent|streamGenerateContent)$`)
|
||||
customGeminiRelayPath = regexp.MustCompile(`(?:^|/)models/[^/:]+:(generateContent|streamGenerateContent)$`)
|
||||
openAIPostEndpoints = map[string]bool{
|
||||
"/responses": true, "/chat/completions": true, "/images/generations": true, "/images/edits": true,
|
||||
"/audio/speech": true,
|
||||
}
|
||||
@@ -35,6 +36,72 @@ func ConfigureRuntime(svc *service.Service) {
|
||||
runtimeService = svc
|
||||
}
|
||||
|
||||
func authorizeCustomRelay(method string, target *url.URL, apiFormat string, contentType string) error {
|
||||
requestPath, err := normalizedCustomRelayPath(target.EscapedPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query, err := url.ParseQuery(target.RawQuery)
|
||||
if err != nil {
|
||||
return errors.New("自定义渠道查询参数无效")
|
||||
}
|
||||
for key := range query {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "key", "api_key", "access_token", "token":
|
||||
return errors.New("自定义渠道地址不允许在查询参数中携带密钥")
|
||||
}
|
||||
}
|
||||
|
||||
apiFormat = strings.ToLower(strings.TrimSpace(apiFormat))
|
||||
if apiFormat != "openai" && apiFormat != "gemini" {
|
||||
return errors.New("自定义渠道调用格式无效")
|
||||
}
|
||||
if method == http.MethodGet {
|
||||
if len(query) != 0 || (requestPath != "/models" && !strings.HasSuffix(requestPath, "/models")) {
|
||||
return errors.New("自定义渠道不允许访问该上游接口")
|
||||
}
|
||||
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("自定义渠道不允许使用该请求方法")
|
||||
}
|
||||
if apiFormat == "openai" {
|
||||
if len(query) != 0 || (!strings.HasSuffix(requestPath, "/responses") && !strings.HasSuffix(requestPath, "/chat/completions")) {
|
||||
return errors.New("自定义渠道不允许访问该上游接口")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !customGeminiRelayPath.MatchString(requestPath) {
|
||||
return errors.New("自定义渠道不允许访问该上游接口")
|
||||
}
|
||||
if len(query) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(query) == 1 && len(query["alt"]) == 1 && query.Get("alt") == "sse" && strings.HasSuffix(requestPath, ":streamGenerateContent") {
|
||||
return nil
|
||||
}
|
||||
return errors.New("自定义渠道不允许使用该查询参数")
|
||||
}
|
||||
|
||||
func normalizedCustomRelayPath(value string) (string, error) {
|
||||
if len(value) > 2048 {
|
||||
return "", errors.New("自定义渠道请求路径过长")
|
||||
}
|
||||
decoded, err := url.PathUnescape(value)
|
||||
if err != nil || strings.Contains(decoded, "\\") || strings.Contains(decoded, "\x00") {
|
||||
return "", errors.New("自定义渠道请求路径无效")
|
||||
}
|
||||
cleaned := path.Clean("/" + strings.TrimPrefix(decoded, "/"))
|
||||
if cleaned != decoded && cleaned != "/"+strings.TrimPrefix(decoded, "/") {
|
||||
return "", errors.New("自定义渠道请求路径无效")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func enforceRateLimit(c *gin.Context, key string, limit int, window time.Duration) bool {
|
||||
if runtimeService == nil {
|
||||
fail(c, http.StatusServiceUnavailable, errors.New("请求协调器尚未初始化"))
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -17,6 +18,54 @@ func TestAuthorizeSystemProxyAllowsConfiguredGenerationModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) {
|
||||
tests := []struct {
|
||||
method string
|
||||
target string
|
||||
apiFormat string
|
||||
contentType string
|
||||
}{
|
||||
{method: http.MethodGet, target: "https://api.example.com/v1/models", apiFormat: "openai"},
|
||||
{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://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", apiFormat: "gemini", contentType: "application/json"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
target, err := url.Parse(test.target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := authorizeCustomRelay(test.method, target, test.apiFormat, test.contentType); err != nil {
|
||||
t.Fatalf("authorizeCustomRelay(%s %s) error = %v", test.method, test.target, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeCustomRelayRejectsArbitraryRequestsAndCredentialQueries(t *testing.T) {
|
||||
tests := []struct {
|
||||
method string
|
||||
target string
|
||||
apiFormat string
|
||||
contentType string
|
||||
}{
|
||||
{method: http.MethodDelete, target: "https://api.example.com/v1/models", apiFormat: "openai"},
|
||||
{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/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"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
target, err := url.Parse(test.target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := authorizeCustomRelay(test.method, target, test.apiFormat, test.contentType); err == nil {
|
||||
t.Fatalf("authorizeCustomRelay(%s %s) should fail", test.method, test.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeSystemProxyRejectsArbitraryPathAndModel(t *testing.T) {
|
||||
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-image-1"]`}
|
||||
if err := authorizeSystemProxy(channel, http.MethodDelete, "/account", "application/json", nil); err == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -13,7 +14,22 @@ import (
|
||||
|
||||
const maxOutboundRedirects = 5
|
||||
|
||||
var outboundTransport = newOutboundTransport()
|
||||
var (
|
||||
outboundTransport = newOutboundTransport(resolveOutboundHost)
|
||||
customRelayTransport = newOutboundTransport(resolveCustomRelayHost)
|
||||
blockedCustomRelayPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
}
|
||||
)
|
||||
|
||||
func ValidateOutboundURL(rawURL string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
@@ -32,6 +48,31 @@ func ValidateOutboundURL(rawURL string) (*url.URL, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// 用户自定义渠道必须使用更严格的出口策略:只允许 HTTPS,不接受 URL 凭据,
|
||||
// 仅部署者精确配置的主机可以路由到私网。
|
||||
func ValidateCustomRelayURL(rawURL string) (*url.URL, error) {
|
||||
if len(strings.TrimSpace(rawURL)) > 4096 {
|
||||
return nil, BadAuthRequest("自定义渠道地址过长")
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" || !parsed.IsAbs() {
|
||||
return nil, BadAuthRequest("自定义渠道地址无效")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return nil, BadAuthRequest("自定义渠道中转只支持 HTTPS")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, BadAuthRequest("自定义渠道地址不允许包含认证信息")
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return nil, BadAuthRequest("自定义渠道地址不允许包含片段")
|
||||
}
|
||||
if err := validateCustomRelayHost(parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func OutboundHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: outboundTransport,
|
||||
@@ -46,7 +87,17 @@ func OutboundHTTPClient(timeout time.Duration) *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
func newOutboundTransport() *http.Transport {
|
||||
func CustomRelayHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: customRelayTransport,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return errors.New("自定义渠道中转不允许重定向")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
@@ -54,7 +105,7 @@ func newOutboundTransport() *http.Transport {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addresses, err := resolveOutboundHost(ctx, host)
|
||||
addresses, err := resolveHost(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -74,12 +125,37 @@ func validateOutboundHost(host string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func validateCustomRelayHost(host string) error {
|
||||
_, err := resolveCustomRelayHost(context.Background(), host)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveOutboundHost(ctx context.Context, host string) ([]net.IP, error) {
|
||||
host = normalizeOutboundHost(host)
|
||||
return resolveOutboundHostWithPolicy(ctx, host, allowPrivateUpstreams() || allowedPrivateUpstreamHost(host))
|
||||
}
|
||||
|
||||
func resolveCustomRelayHost(ctx context.Context, host string) ([]net.IP, error) {
|
||||
host = normalizeOutboundHost(host)
|
||||
allowPrivateHost := allowedPrivateUpstreamHost(host)
|
||||
addresses, err := resolveOutboundHostWithPolicy(ctx, host, allowPrivateHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !allowPrivateHost {
|
||||
for _, ip := range addresses {
|
||||
if blockedCustomRelayIP(ip) {
|
||||
return nil, BadAuthRequest("不允许访问保留地址或特殊用途地址")
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func resolveOutboundHostWithPolicy(ctx context.Context, host string, allowPrivateHost bool) ([]net.IP, error) {
|
||||
if host == "" {
|
||||
return nil, BadAuthRequest("外部服务域名无效")
|
||||
}
|
||||
allowPrivateHost := allowPrivateUpstreams() || allowedPrivateUpstreamHost(host)
|
||||
if !allowPrivateHost && (host == "localhost" || strings.HasSuffix(host, ".localhost")) {
|
||||
return nil, BadAuthRequest("不允许访问本机或内网地址")
|
||||
}
|
||||
@@ -104,6 +180,23 @@ func blockedOutboundIP(ip net.IP) bool {
|
||||
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast()
|
||||
}
|
||||
|
||||
func blockedCustomRelayIP(ip net.IP) bool {
|
||||
if blockedOutboundIP(ip) {
|
||||
return true
|
||||
}
|
||||
address, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
address = address.Unmap()
|
||||
for _, prefix := range blockedCustomRelayPrefixes {
|
||||
if prefix.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allowPrivateUpstreams() bool {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS")))
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateOutboundURLRejectsPrivateHosts(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
|
||||
@@ -40,3 +46,63 @@ func TestAllowedPrivateUpstreamHostUsesExactCaseInsensitiveMatch(t *testing.T) {
|
||||
t.Fatal("allowedPrivateUpstreamHost() should reject hostname suffix confusion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCustomRelayURLUsesHTTPSAndExactPrivateAllowlist(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
|
||||
if _, err := ValidateCustomRelayURL("http://127.0.0.1:8080/v1/models"); err == nil {
|
||||
t.Fatal("ValidateCustomRelayURL() should reject HTTP")
|
||||
}
|
||||
if _, err := ValidateCustomRelayURL("https://127.0.0.1:8080/v1/models"); err == nil {
|
||||
t.Fatal("ValidateCustomRelayURL() should ignore the global private upstream override")
|
||||
}
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
if _, err := ValidateCustomRelayURL("https://127.0.0.1:8080/v1/models"); err != nil {
|
||||
t.Fatalf("ValidateCustomRelayURL() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCustomRelayURLRejectsCredentialsAndFragment(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
for _, rawURL := range []string{
|
||||
"https://user:pass@127.0.0.1/v1/models",
|
||||
"https://127.0.0.1/v1/models#secret",
|
||||
} {
|
||||
if _, err := ValidateCustomRelayURL(rawURL); err == nil {
|
||||
t.Fatalf("ValidateCustomRelayURL(%q) should fail", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedCustomRelayIPRejectsCarrierGradeNATAndReservedRanges(t *testing.T) {
|
||||
for _, value := range []string{"100.100.100.200", "192.0.2.10", "198.18.0.1", "2001:db8::1"} {
|
||||
if !blockedCustomRelayIP(net.ParseIP(value)) {
|
||||
t.Fatalf("blockedCustomRelayIP(%q) = false", value)
|
||||
}
|
||||
}
|
||||
if blockedCustomRelayIP(net.ParseIP("8.8.8.8")) {
|
||||
t.Fatal("blockedCustomRelayIP() rejected a public address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRelayHTTPClientDoesNotFollowRedirects(t *testing.T) {
|
||||
redirected := false
|
||||
destination := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
redirected = true
|
||||
}))
|
||||
defer destination.Close()
|
||||
source := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", destination.URL)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer source.Close()
|
||||
|
||||
client := CustomRelayHTTPClient(time.Second)
|
||||
client.Transport = source.Client().Transport
|
||||
if _, err := client.Get(source.URL); err == nil {
|
||||
t.Fatal("CustomRelayHTTPClient() should reject redirects")
|
||||
}
|
||||
if redirected {
|
||||
t.Fatal("redirect destination should not receive the request")
|
||||
}
|
||||
}
|
||||
|
||||
28
web/src/services/api/custom-channel-relay.ts
Normal file
28
web/src/services/api/custom-channel-relay.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">;
|
||||
|
||||
export type ChannelRequest = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
credentials: RequestCredentials;
|
||||
};
|
||||
|
||||
/** 自定义渠道统一经登录态后端中转,避免依赖第三方服务的浏览器 CORS。 */
|
||||
export function channelRequest(config: RelayConfig, upstreamUrl: string, headers: HeadersInit = {}): ChannelRequest {
|
||||
const normalizedHeaders = new Headers(headers);
|
||||
if (isSystemProxyBaseUrl(config.baseUrl)) {
|
||||
return { url: upstreamUrl, headers: Object.fromEntries(normalizedHeaders.entries()), credentials: "include" };
|
||||
}
|
||||
|
||||
const normalizedUpstreamUrl = new URL(upstreamUrl).toString();
|
||||
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");
|
||||
return {
|
||||
url: resolveBackendApiUrl("/api/ai/custom"),
|
||||
headers: Object.fromEntries(normalizedHeaders.entries()),
|
||||
credentials: "include",
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRe
|
||||
import { nanoid } from "nanoid";
|
||||
import { dataUrlToFile } from "@/lib/image-utils";
|
||||
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
|
||||
import { channelRequest } from "@/services/api/custom-channel-relay";
|
||||
import { imageToDataUrl } from "@/services/image-storage";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
|
||||
@@ -249,10 +250,6 @@ function aiHeaders(config: AiConfig, contentType?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function aiFetchCredentials(config: Pick<AiConfig, "baseUrl">): RequestCredentials {
|
||||
return isSystemProxyBaseUrl(config.baseUrl) ? "include" : "same-origin";
|
||||
}
|
||||
|
||||
function geminiBaseUrl(config: Pick<AiConfig, "baseUrl">) {
|
||||
const normalizedBaseUrl = resolveBackendApiUrl(config.baseUrl).replace(/\/+$/, "");
|
||||
const lowerBaseUrl = normalizedBaseUrl.toLowerCase();
|
||||
@@ -454,12 +451,13 @@ function consumeResponseStreamText(state: ResponseStreamState, text: string, onD
|
||||
}
|
||||
|
||||
async function requestStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
|
||||
const response = await fetch(aiApiUrl(config, "/responses"), {
|
||||
const request = channelRequest(config, aiApiUrl(config, "/responses"), { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" });
|
||||
const response = await fetch(request.url, {
|
||||
method: "POST",
|
||||
headers: { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" },
|
||||
headers: request.headers,
|
||||
body: JSON.stringify({ ...body, stream: true }),
|
||||
signal: options?.signal,
|
||||
credentials: aiFetchCredentials(config),
|
||||
credentials: request.credentials,
|
||||
});
|
||||
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
|
||||
if (!response.body) {
|
||||
@@ -534,12 +532,13 @@ function consumeChatCompletionStreamText(state: ChatCompletionStreamState, text:
|
||||
}
|
||||
|
||||
async function requestStreamingChatCompletion(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
|
||||
const response = await fetch(aiApiUrl(config, "/chat/completions"), {
|
||||
const request = channelRequest(config, aiApiUrl(config, "/chat/completions"), { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" });
|
||||
const response = await fetch(request.url, {
|
||||
method: "POST",
|
||||
headers: { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" },
|
||||
headers: request.headers,
|
||||
body: JSON.stringify({ ...body, stream: true }),
|
||||
signal: options?.signal,
|
||||
credentials: aiFetchCredentials(config),
|
||||
credentials: request.credentials,
|
||||
});
|
||||
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
@@ -640,12 +639,13 @@ function toGeminiToolOptions(tools: ResponseFunctionTool[], toolChoice: ToolChoi
|
||||
}
|
||||
|
||||
async function requestGeminiStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
|
||||
const response = await fetch(`${geminiApiUrl(config, "streamGenerateContent")}?alt=sse`, {
|
||||
const request = channelRequest(config, `${geminiApiUrl(config, "streamGenerateContent")}?alt=sse`, geminiHeaders(config));
|
||||
const response = await fetch(request.url, {
|
||||
method: "POST",
|
||||
headers: geminiHeaders(config),
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
credentials: aiFetchCredentials(config),
|
||||
credentials: request.credentials,
|
||||
});
|
||||
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
|
||||
if (!response.body) {
|
||||
@@ -887,18 +887,17 @@ export async function requestToolResponse(config: AiConfig, messages: ResponseIn
|
||||
export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">) {
|
||||
try {
|
||||
if (config.apiFormat === "gemini") {
|
||||
const response = await axios.get<GeminiPayload>(geminiApiUrl({ ...defaultGeminiConfig, ...config }), { headers: geminiHeaders({ ...defaultGeminiConfig, ...config }) });
|
||||
const requestConfig = { ...defaultGeminiConfig, ...config };
|
||||
const request = channelRequest(requestConfig, geminiApiUrl(requestConfig), geminiHeaders(requestConfig));
|
||||
const response = await axios.get<GeminiPayload>(request.url, { headers: request.headers, withCredentials: request.credentials === "include" });
|
||||
validateGeminiPayload(response.data);
|
||||
return (response.data.models || [])
|
||||
.map((model) => model.name?.replace(/^models\//, ""))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
});
|
||||
const request = channelRequest(config, buildApiUrl(config.baseUrl, "/models"), { Authorization: `Bearer ${config.apiKey}` });
|
||||
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(request.url, { headers: request.headers, withCredentials: request.credentials === "include" });
|
||||
return (response.data.data || [])
|
||||
.map((model) => model.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
|
||||
Reference in New Issue
Block a user