mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #6139 from xz-dev/fix/configurable-model-list-read-limit
feat(gateway): configure model list read limit
This commit is contained in:
@@ -194,7 +194,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService)
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
|
||||
|
||||
@@ -61,6 +61,10 @@ const (
|
||||
// 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。
|
||||
const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024
|
||||
|
||||
// DefaultModelsListReadMaxBytes 上游模型列表响应体的默认读取上限。
|
||||
// 可通过 gateway.models_list_read_max_bytes 配置项覆盖。
|
||||
const DefaultModelsListReadMaxBytes int64 = 8 * 1024 * 1024
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
@@ -960,6 +964,8 @@ type GatewayConfig struct {
|
||||
TextMaxBodySize int64 `mapstructure:"text_max_body_size"`
|
||||
// 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大
|
||||
UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"`
|
||||
// 上游模型列表响应体读取上限(字节)
|
||||
ModelsListReadMaxBytes int64 `mapstructure:"models_list_read_max_bytes"`
|
||||
// 代理探测响应体读取上限(字节)
|
||||
ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"`
|
||||
// Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销)
|
||||
@@ -2456,6 +2462,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
|
||||
viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024))
|
||||
viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes)
|
||||
viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes)
|
||||
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
|
||||
viper.SetDefault("gateway.gemini_debug_response_headers", false)
|
||||
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
|
||||
@@ -3265,6 +3272,9 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.UpstreamResponseReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive")
|
||||
}
|
||||
if c.Gateway.ModelsListReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.models_list_read_max_bytes must be positive")
|
||||
}
|
||||
if c.Gateway.ProxyProbeResponseReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.proxy_probe_response_read_max_bytes must be positive")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ func resetViperWithJWTSecret(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
|
||||
}
|
||||
|
||||
func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes)
|
||||
}
|
||||
|
||||
func TestLoadTimezonePrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1803,6 +1810,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 },
|
||||
wantErr: "gateway.text_max_body_size",
|
||||
},
|
||||
{
|
||||
name: "gateway models list read limit",
|
||||
mutate: func(c *Config) { c.Gateway.ModelsListReadMaxBytes = 0 },
|
||||
wantErr: "gateway.models_list_read_max_bytes",
|
||||
},
|
||||
{
|
||||
name: "gateway response header timeout",
|
||||
mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 },
|
||||
|
||||
@@ -255,8 +255,6 @@ const (
|
||||
proxyTLSHandshakeTimeout = 5 * time.Second
|
||||
// clientTimeout 整体请求超时(含连接、发送、等待响应、读取 body)
|
||||
clientTimeout = 10 * time.Second
|
||||
// fetchAvailableModelsBodyLimit limits model-list responses to avoid unbounded memory use.
|
||||
fetchAvailableModelsBodyLimit int64 = 8 << 20
|
||||
)
|
||||
|
||||
func NewClient(proxyURL string) (*Client, error) {
|
||||
@@ -656,10 +654,13 @@ type FetchAvailableModelsResponse struct {
|
||||
|
||||
// FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON
|
||||
// 支持 URL fallback:sandbox → daily → prod
|
||||
func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string) (*FetchAvailableModelsResponse, map[string]any, error) {
|
||||
func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil, nil, errors.New("antigravity client is not configured")
|
||||
}
|
||||
if bodyLimit <= 0 {
|
||||
return nil, nil, errors.New("fetchAvailableModels body limit must be positive")
|
||||
}
|
||||
|
||||
reqBody := FetchAvailableModelsRequest{Project: projectID}
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
@@ -693,13 +694,13 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
|
||||
respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, fetchAvailableModelsBodyLimit+1))
|
||||
respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
|
||||
_ = resp.Body.Close() // 立即关闭,避免循环内 defer 导致的资源泄漏
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
if int64(len(respBodyBytes)) > fetchAvailableModelsBodyLimit {
|
||||
return nil, nil, fmt.Errorf("响应超过 %d 字节", fetchAvailableModelsBodyLimit)
|
||||
if int64(len(respBodyBytes)) > bodyLimit {
|
||||
return nil, nil, fmt.Errorf("响应超过 %d 字节", bodyLimit)
|
||||
}
|
||||
|
||||
// 检查是否需要 URL 降级
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultFetchAvailableModelsBodyLimit int64 = 8 << 20
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NewAPIRequestWithURL
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1517,7 +1519,7 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc")
|
||||
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc", defaultFetchAvailableModelsBodyLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAvailableModels 失败: %v", err)
|
||||
}
|
||||
@@ -1562,6 +1564,34 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModels_RejectsNonPositiveBodyLimit(t *testing.T) {
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "body limit must be positive") {
|
||||
t.Fatalf("非正读取上限应返回调用错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModels_UsesConfiguredBodyLimit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"models":{"model-a":{}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 8)
|
||||
if err == nil {
|
||||
t.Fatal("响应超过配置上限时应返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "响应超过 8 字节") {
|
||||
t.Fatalf("错误应包含配置上限: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
@@ -1572,7 +1602,7 @@ func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil {
|
||||
t.Fatal("服务器返回 403 时应返回错误")
|
||||
}
|
||||
@@ -1592,7 +1622,7 @@ func TestClient_FetchAvailableModels_InvalidJSON_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil {
|
||||
t.Fatal("无效 JSON 响应应返回错误")
|
||||
}
|
||||
@@ -1622,7 +1652,7 @@ func TestClient_FetchAvailableModels_URLFallback_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server1.URL, server2.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAvailableModels 应在 fallback 后成功: %v", err)
|
||||
}
|
||||
@@ -1650,7 +1680,7 @@ func TestClient_FetchAvailableModels_AllURLsFail_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server1.URL, server2.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil {
|
||||
t.Fatal("所有 URL 都失败时应返回错误")
|
||||
}
|
||||
@@ -1669,7 +1699,7 @@ func TestClient_FetchAvailableModels_ContextCanceled_RealCall(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, _, err := client.FetchAvailableModels(ctx, "token", "proj")
|
||||
_, _, err := client.FetchAvailableModels(ctx, "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil {
|
||||
t.Fatal("context 取消时应返回错误")
|
||||
}
|
||||
@@ -1686,7 +1716,7 @@ func TestClient_FetchAvailableModels_EmptyModels_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAvailableModels 失败: %v", err)
|
||||
}
|
||||
@@ -1748,7 +1778,7 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server1.URL, server2.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAvailableModels 应在 404 fallback 后成功: %v", err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
@@ -28,11 +29,12 @@ const (
|
||||
// AntigravityQuotaFetcher 从 Antigravity API 获取额度
|
||||
type AntigravityQuotaFetcher struct {
|
||||
proxyRepo ProxyRepository
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewAntigravityQuotaFetcher 创建 AntigravityQuotaFetcher
|
||||
func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository) *AntigravityQuotaFetcher {
|
||||
return &AntigravityQuotaFetcher{proxyRepo: proxyRepo}
|
||||
func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository, cfg *config.Config) *AntigravityQuotaFetcher {
|
||||
return &AntigravityQuotaFetcher{proxyRepo: proxyRepo, cfg: cfg}
|
||||
}
|
||||
|
||||
// CanFetch 检查是否可以获取此账户的额度
|
||||
@@ -55,7 +57,7 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou
|
||||
}
|
||||
|
||||
// 调用 API 获取配额
|
||||
modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID)
|
||||
modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID, resolveModelsListReadLimit(f.cfg))
|
||||
if err != nil {
|
||||
// 403 Forbidden: 不报错,返回 is_forbidden 标记
|
||||
var forbiddenErr *antigravity.ForbiddenError
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
@@ -390,6 +395,36 @@ func TestBuildUsageInfo_AICredits(t *testing.T) {
|
||||
require.Equal(t, 5.0, info.AICredits[0].MinimumBalance)
|
||||
}
|
||||
|
||||
func TestFetchQuotaUsesConfiguredModelsListBodyLimit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"models":{"model-a":{}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
|
||||
oldAvailability := antigravity.DefaultURLAvailability
|
||||
t.Cleanup(func() {
|
||||
antigravity.BaseURLs = oldBaseURLs
|
||||
antigravity.DefaultURLAvailability = oldAvailability
|
||||
})
|
||||
antigravity.BaseURLs = []string{server.URL}
|
||||
antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.ModelsListReadMaxBytes = 8
|
||||
fetcher := NewAntigravityQuotaFetcher(nil, cfg)
|
||||
_, err := fetcher.FetchQuota(context.Background(), &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "project",
|
||||
},
|
||||
}, "")
|
||||
require.ErrorContains(t, err, "响应超过 8 字节")
|
||||
}
|
||||
|
||||
func TestFetchQuota_ForbiddenReturnsIsForbidden(t *testing.T) {
|
||||
// 模拟 FetchQuota 遇到 403 时的行为:
|
||||
// FetchAvailableModels 返回 ForbiddenError → FetchQuota 应返回 is_forbidden=true
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package service
|
||||
|
||||
import "github.com/Wei-Shaw/sub2api/internal/config"
|
||||
|
||||
func resolveModelsListReadLimit(cfg *config.Config) int64 {
|
||||
if cfg != nil && cfg.Gateway.ModelsListReadMaxBytes > 0 {
|
||||
return cfg.Gateway.ModelsListReadMaxBytes
|
||||
}
|
||||
return config.DefaultModelsListReadMaxBytes
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveModelsListReadLimit(t *testing.T) {
|
||||
t.Run("nil config uses default", func(t *testing.T) {
|
||||
require.Equal(t, config.DefaultModelsListReadMaxBytes, resolveModelsListReadLimit(nil))
|
||||
})
|
||||
|
||||
t.Run("configured limit wins", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.ModelsListReadMaxBytes = 16 << 20
|
||||
require.Equal(t, int64(16<<20), resolveModelsListReadLimit(cfg))
|
||||
})
|
||||
|
||||
t.Run("non-positive limit uses default", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
require.Equal(t, config.DefaultModelsListReadMaxBytes, resolveModelsListReadLimit(cfg))
|
||||
})
|
||||
}
|
||||
@@ -27,12 +27,11 @@ import (
|
||||
var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models"
|
||||
|
||||
const (
|
||||
codexModelsManifestBodyLimit int64 = 8 << 20
|
||||
codexModelsManifestCacheBodyLimit = 1 << 20
|
||||
codexModelsManifestCacheMaxEntries = 64
|
||||
codexModelsManifestCacheTTL = 30 * time.Second
|
||||
codexModelsManifestCacheStaleTTL = 5 * time.Minute
|
||||
codexModelsManifestRequestTimeout = 15 * time.Second
|
||||
codexModelsManifestCacheBodyLimit = 1 << 20
|
||||
codexModelsManifestCacheMaxEntries = 64
|
||||
codexModelsManifestCacheTTL = 30 * time.Second
|
||||
codexModelsManifestCacheStaleTTL = 5 * time.Minute
|
||||
codexModelsManifestRequestTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// CodexModelsManifest carries the client representation plus caching metadata.
|
||||
@@ -515,13 +514,20 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, codexModelsManifestBodyLimit))
|
||||
bodyLimit := resolveModelsListReadLimit(s.cfg)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
|
||||
if err != nil {
|
||||
return nil, &codexModelsManifestUpstreamError{
|
||||
err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err),
|
||||
retryable: isRetryableCodexModelsManifestTransportError(err),
|
||||
}
|
||||
}
|
||||
if int64(len(body)) > bodyLimit {
|
||||
return nil, &codexModelsManifestUpstreamError{
|
||||
err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest response exceeds %d bytes", bodyLimit),
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
upstreamBody := body
|
||||
if request.useAPIKeyUpstream {
|
||||
body = convertOpenAIModelListToCodexManifest(body)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -623,6 +624,51 @@ func TestConvertOpenAIModelListToCodexManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestUsesConfiguredBodyLimit(t *testing.T) {
|
||||
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6"}]}`)),
|
||||
}, nil
|
||||
}}
|
||||
|
||||
s := newCodexModelsAPIKeyTestService(upstream)
|
||||
s.cfg.Gateway.ModelsListReadMaxBytes = 8
|
||||
_, err := s.FetchCodexModelsManifest(
|
||||
context.Background(),
|
||||
newCodexModelsAPIKeyTestAccount("https://upstream.example"),
|
||||
"0.144.0",
|
||||
"",
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err))
|
||||
require.Contains(t, err.Error(), "response exceeds 8 bytes")
|
||||
require.True(t, IsRetryableCodexModelsManifestError(err))
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestAcceptsConfiguredLimitAboveLegacyBoundary(t *testing.T) {
|
||||
manifestBody := `{"models":[{"slug":"gpt-5.6","display_name":"` + strings.Repeat("x", (8<<20)+1024) + `"}]}`
|
||||
require.Greater(t, len(manifestBody), 8<<20)
|
||||
require.Less(t, len(manifestBody), 16<<20)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, manifestBody)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
original := chatgptCodexModelsURL
|
||||
chatgptCodexModelsURL = server.URL
|
||||
defer func() { chatgptCodexModelsURL = original }()
|
||||
|
||||
s := &OpenAIGatewayService{cfg: &config.Config{}}
|
||||
s.cfg.Gateway.ModelsListReadMaxBytes = 16 << 20
|
||||
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.144.0", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, bytes.Equal([]byte(manifestBody), manifest.Body), "manifest body must be returned intact")
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
)
|
||||
|
||||
const upstreamModelsBodyLimit int64 = 8 << 20
|
||||
|
||||
// UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping.
|
||||
type UpstreamModelSyncErrorKind string
|
||||
|
||||
@@ -101,12 +99,13 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1))
|
||||
bodyLimit := resolveModelsListReadLimit(s.cfg)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err)
|
||||
}
|
||||
if int64(len(body)) > upstreamModelsBodyLimit {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", upstreamModelsBodyLimit))
|
||||
if int64(len(body)) > bodyLimit {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit))
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
@@ -506,7 +505,12 @@ func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Con
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err)
|
||||
}
|
||||
modelsResp, _, err := client.FetchAvailableModels(ctx, accessToken, strings.TrimSpace(account.GetCredential("project_id")))
|
||||
modelsResp, _, err := client.FetchAvailableModels(
|
||||
ctx,
|
||||
accessToken,
|
||||
strings.TrimSpace(account.GetCredential("project_id")),
|
||||
resolveModelsListReadLimit(s.cfg),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err)
|
||||
}
|
||||
|
||||
@@ -394,6 +394,31 @@ func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) {
|
||||
require.Equal(t, "Bearer openai-key", upstream.lastReq.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"}]}`)),
|
||||
}}
|
||||
cfg := upstreamModelSyncTestConfig()
|
||||
cfg.Gateway.ModelsListReadMaxBytes = 8
|
||||
svc := &AccountTestService{httpUpstream: upstream, cfg: cfg}
|
||||
|
||||
_, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{
|
||||
ID: 7,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "openai-key",
|
||||
"base_url": "https://openai.example.com/v1",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "response exceeds 8 bytes")
|
||||
}
|
||||
|
||||
func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -235,6 +235,9 @@ gateway:
|
||||
# Max bytes to read for non-stream upstream responses (default: 8MB)
|
||||
# 非流式上游响应体读取上限(默认 8MB)
|
||||
upstream_response_read_max_bytes: 8388608
|
||||
# Max bytes to read for upstream model-list responses (default: 8MB)
|
||||
# 上游模型列表响应体读取上限(默认 8MB)
|
||||
models_list_read_max_bytes: 8388608
|
||||
# Max bytes to read for proxy probe responses (default: 1MB)
|
||||
# 代理探测响应体读取上限(默认 1MB)
|
||||
proxy_probe_response_read_max_bytes: 1048576
|
||||
|
||||
Reference in New Issue
Block a user