diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 31e7c9c405..a63a9fe14c 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.158 +0.1.159 diff --git a/backend/internal/handler/admin/audit_log_handler.go b/backend/internal/handler/admin/audit_log_handler.go index 67d528a1e5..a54135c86e 100644 --- a/backend/internal/handler/admin/audit_log_handler.go +++ b/backend/internal/handler/admin/audit_log_handler.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -150,7 +149,7 @@ func (h *AuditLogHandler) Clear(c *gin.Context) { CredentialMasked: middleware.MaskedRequestCredential(c), Method: http.MethodPost, Path: c.FullPath(), - ClientIP: ip.GetTrustedClientIP(c), + ClientIP: middleware.SecurityClientIP(c), UserAgent: c.Request.UserAgent(), StatusCode: http.StatusOK, } diff --git a/backend/internal/pkg/ip/ip.go b/backend/internal/pkg/ip/ip.go index f6f77c86e7..2702b13531 100644 --- a/backend/internal/pkg/ip/ip.go +++ b/backend/internal/pkg/ip/ip.go @@ -54,6 +54,17 @@ func GetTrustedClientIP(c *gin.Context) string { return normalizeIP(c.ClientIP()) } +// GetSecurityClientIP 返回安全敏感场景(API Key IP 限制、审计日志、会话 IP/UA 绑定) +// 使用的客户端 IP。trustForwarded 对应系统设置「信任反代传递的客户端 IP」: +// 开启时信任反代转发头(CF-Connecting-IP / X-Real-IP / X-Forwarded-For), +// 关闭时走 Gin trusted_proxies 解析链。 +func GetSecurityClientIP(c *gin.Context, trustForwarded bool) string { + if trustForwarded { + return GetClientIP(c) + } + return GetTrustedClientIP(c) +} + // normalizeIP 规范化 IP 地址,去除端口号和空格。 func normalizeIP(ip string) string { ip = strings.TrimSpace(ip) diff --git a/backend/internal/pkg/ip/ip_test.go b/backend/internal/pkg/ip/ip_test.go index 403b2d59e7..7e579bf7a1 100644 --- a/backend/internal/pkg/ip/ip_test.go +++ b/backend/internal/pkg/ip/ip_test.go @@ -94,3 +94,33 @@ func TestCheckIPRestrictionWithCompiledRules_InvalidWhitelistStillDenies(t *test require.False(t, allowed) require.Equal(t, "access denied", reason) } + +func TestGetSecurityClientIPHonorsTrustToggle(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, tc := range []struct { + name string + trustForwarded bool + want string + }{ + {name: "trust disabled uses trusted proxy chain", trustForwarded: false, want: "9.9.9.9"}, + {name: "trust enabled uses forwarded header", trustForwarded: true, want: "1.2.3.4"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.GET("/t", func(c *gin.Context) { + c.String(200, GetSecurityClientIP(c, tc.trustForwarded)) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + req.Header.Set("X-Real-IP", "1.2.3.4") + r.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + require.Equal(t, tc.want, w.Body.String()) + }) + } +} diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index a8fece044c..b9013815a2 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -97,10 +97,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti // 检查 IP 限制(白名单/黑名单) // 注意:错误信息故意模糊,避免暴露具体的 IP 限制机制 if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 { - clientIP := ip.GetTrustedClientIP(c) - if cfg.TrustForwardedIPForAPIKeyACL() { - clientIP = ip.GetClientIP(c) - } + clientIP := ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL()) allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist) if !allowed { if clientIP == "" { diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index b910dd9fa4..49bf6b4268 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -59,10 +59,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs // 检查 IP 限制(白名单/黑名单)。与主中间件保持一致,避免 Gemini 端点绕过 Key 的 IP ACL。 if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 { - clientIP := ip.GetTrustedClientIP(c) - if cfg.TrustForwardedIPForAPIKeyACL() { - clientIP = ip.GetClientIP(c) - } + clientIP := ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL()) allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist) if !allowed { if clientIP == "" { diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index 1cb510f06b..16403a82bd 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -7,7 +7,6 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -147,7 +146,7 @@ func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddle Action: action, Method: c.Request.Method, Path: c.FullPath(), - ClientIP: ip.GetTrustedClientIP(c), + ClientIP: SecurityClientIP(c), UserAgent: c.Request.UserAgent(), RequestBody: bodyRedacted, StatusCode: status, diff --git a/backend/internal/server/middleware/session_binding.go b/backend/internal/server/middleware/session_binding.go index b31e2a39e7..31edd110b6 100644 --- a/backend/internal/server/middleware/session_binding.go +++ b/backend/internal/server/middleware/session_binding.go @@ -1,19 +1,25 @@ package middleware import ( + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) -// SessionBindingContext 全局中间件:将请求的可信客户端 IP 与 User-Agent 注入 -// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定。 -// 必须使用 GetTrustedClientIP(走 trusted_proxies 链),不可信头会导致绑定被伪造绕过。 -func SessionBindingContext() gin.HandlerFunc { +// SessionBindingContext 全局中间件:将请求的客户端 IP 与 User-Agent 注入 +// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定, +// 同时作为审计日志、会话绑定校验的统一客户端 IP 来源。 +// IP 取值与 API Key IP 限制共用「信任反代传递的客户端 IP」系统开关: +// 开启时信任反代转发头(CF-Connecting-IP / X-Real-IP / X-Forwarded-For), +// 关闭时走 trusted_proxies 解析链,避免不可信头伪造绕过绑定。 +func SessionBindingContext(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { binding := &service.SessionBinding{ - IP: ip.GetTrustedClientIP(c), + IP: ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL()), UserAgent: c.Request.UserAgent(), } c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding)) @@ -21,13 +27,27 @@ func SessionBindingContext() gin.HandlerFunc { } } -// currentSessionBindingHash 计算当前请求的会话指纹哈希。 -func currentSessionBindingHash(c *gin.Context) string { - binding := &service.SessionBinding{ +// requestSessionBinding 返回当前请求的会话指纹,优先取 SessionBindingContext +// 注入的解析结果(保证与 token 签发路径取值一致);注入缺失时按 trusted_proxies +// 链回退兜底(等价于开关关闭时的行为)。 +func requestSessionBinding(c *gin.Context) *service.SessionBinding { + if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil { + return binding + } + return &service.SessionBinding{ IP: ip.GetTrustedClientIP(c), UserAgent: c.Request.UserAgent(), } - return binding.Hash() +} + +// SecurityClientIP 返回当前请求用于安全敏感记录(审计日志等)的客户端 IP。 +// 与会话绑定、API Key IP 限制共用同一套「信任反代传递的客户端 IP」开关语义。 +func SecurityClientIP(c *gin.Context) string { + if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil && + strings.TrimSpace(binding.IP) != "" { + return binding.IP + } + return ip.GetTrustedClientIP(c) } // enforceSessionBinding 校验 access token 的会话指纹(IP/UA 绑定)。 @@ -49,7 +69,8 @@ func enforceSessionBinding( if claims == nil || claims.BindingHash == "" { return true } - current := currentSessionBindingHash(c) + binding := requestSessionBinding(c) + current := binding.Hash() if current == "" || current == claims.BindingHash { return true } @@ -71,7 +92,7 @@ func enforceSessionBinding( Action: service.AuditActionSessionBindingMismatch, Method: c.Request.Method, Path: path, - ClientIP: ip.GetTrustedClientIP(c), + ClientIP: binding.IP, UserAgent: c.Request.UserAgent(), StatusCode: 401, }) diff --git a/backend/internal/server/middleware/session_binding_test.go b/backend/internal/server/middleware/session_binding_test.go new file mode 100644 index 0000000000..2823dc6eef --- /dev/null +++ b/backend/internal/server/middleware/session_binding_test.go @@ -0,0 +1,103 @@ +//go:build unit + +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 反代场景:RemoteAddr 为 127.0.0.1,真实客户端 IP 在 X-Real-IP 中。 +// 会话绑定注入与审计 IP 必须与 API Key IP 限制共用「信任反代传递的客户端 IP」开关语义。 +func TestSessionBindingContextHonorsTrustForwardedToggle(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, tc := range []struct { + name string + trustForwarded bool + wantIP string + }{ + {name: "trust disabled records proxy address", trustForwarded: false, wantIP: "127.0.0.1"}, + {name: "trust enabled records forwarded client IP", trustForwarded: true, wantIP: "1.2.3.4"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.SetTrustForwardedIPForAPIKeyACL(tc.trustForwarded) + + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.Use(SessionBindingContext(cfg)) + r.GET("/t", func(c *gin.Context) { + binding := service.SessionBindingFromContext(c.Request.Context()) + require.NotNil(t, binding) + require.Equal(t, tc.wantIP, binding.IP) + require.Equal(t, "test-agent", binding.UserAgent) + require.Equal(t, tc.wantIP, SecurityClientIP(c)) + c.Status(200) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "127.0.0.1:54321" + req.Header.Set("X-Real-IP", "1.2.3.4") + req.Header.Set("User-Agent", "test-agent") + r.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + }) + } +} + +// 未经过 SessionBindingContext 注入时(异常挂载顺序/单测直调),回退 trusted_proxies 链, +// 等价于开关关闭时的历史行为。 +func TestSecurityClientIPFallsBackWithoutInjectedBinding(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.GET("/t", func(c *gin.Context) { + c.String(200, SecurityClientIP(c)) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + req.Header.Set("X-Real-IP", "1.2.3.4") + r.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + require.Equal(t, "9.9.9.9", w.Body.String()) +} + +// requestSessionBinding 优先取注入值:开关开启时校验哈希必须基于注入的转发 IP 计算, +// 与 token 签发路径取值一致,否则同一客户端会被误判为指纹变化。 +func TestRequestSessionBindingPrefersInjectedBinding(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &config.Config{} + cfg.SetTrustForwardedIPForAPIKeyACL(true) + + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.Use(SessionBindingContext(cfg)) + r.GET("/t", func(c *gin.Context) { + issued := &service.SessionBinding{IP: "1.2.3.4", UserAgent: "test-agent"} + require.Equal(t, issued.Hash(), requestSessionBinding(c).Hash()) + c.Status(200) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "127.0.0.1:54321" + req.Header.Set("X-Real-IP", "1.2.3.4") + req.Header.Set("User-Agent", "test-agent") + r.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 0030ee5cbd..5396a94ebc 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -54,8 +54,9 @@ func SetupRouter( // 应用中间件 r.Use(middleware2.RequestLogger()) - // 将可信客户端 IP + UA 注入 request context,供 token 签发路径写入会话绑定 - r.Use(middleware2.SessionBindingContext()) + // 将客户端 IP + UA 注入 request context,供 token 签发/会话绑定/审计日志统一读取。 + // IP 取值与 API Key IP 限制共用「信任反代传递的客户端 IP」系统开关。 + r.Use(middleware2.SessionBindingContext(cfg)) r.Use(middleware2.Logger()) r.Use(middleware2.CORS(cfg.CORS)) r.Use(middleware2.SecurityHeaders(cfg.Security.CSP, func() []string { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index f5e0c07d04..7249256319 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1443,10 +1443,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa // 配置集校验。 capability = OpenAIEndpointCapabilityChatCompletions case OpenAIEndpointCapabilityAlphaSearch: - // Codex alpha/search 是 ChatGPT/Codex 后端工具端点,必须使用 - // OAuth/PAT/AgentIdentity 这类 ChatGPT 账号凭据;API key 被发往 - // chatgpt.com/backend-api/codex/alpha/search 会稳定 401。 - if a.Type != AccountTypeOAuth { + // alpha/search 的转发按账号类型分流:OAuth/PAT 走 + // chatgpt.com/backend-api/codex/alpha/search,API key 走 + // {base_url}/v1/alpha/search(见 openAIAlphaSearchURL),两类账号 + // 都可承接独立搜索请求。上游不支持该端点时由转发层 failover 兜底。 + if a.Type != AccountTypeOAuth && a.Type != AccountTypeAPIKey { return false } case OpenAIEndpointCapabilityEmbeddings: diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index f952617021..25b563281f 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -668,6 +668,53 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_ResponsesCapabilityExcl }) } +// alpha/search 调度必须同时放行 OAuth 与 APIKey 账号:v0.1.157 曾因 OAuth-only +// 门控把 APIKey 账号从候选池剔除,纯 APIKey 分组的独立搜索请求在选号阶段就 +// 报无可用账号,Codex 网页搜索整体失效(转发层其实一直支持 APIKey 路径)。 +func TestOpenAIGatewayService_SelectAccountWithScheduler_AlphaSearchAllowsAPIKeyAccount(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(10125) + accounts := []Account{ + { + ID: 38001, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + }, + } + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = false + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, _, err := svc.SelectAccountWithSchedulerForCapability( + ctx, + &groupID, + "", + "", + "gpt-5.6-sol", + nil, + OpenAIUpstreamTransportHTTPSSE, + OpenAIEndpointCapabilityAlphaSearch, + false, + false, + false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(38001), selection.Account.ID) +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_AllowsGrokChatAccount(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index d220cb7a1e..f11a21958f 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -87,7 +87,8 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co if resp.StatusCode >= http.StatusBadRequest { upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) || + isOpenAIAlphaSearchEndpointUnsupported(account, resp.StatusCode) { resp.Body = io.NopCloser(bytes.NewReader(respBody)) // alpha/search 是独立的工具端点,单次 401 不能证明账号的模型调用 // 凭据全局失效。若沿用通用 401 逻辑,PAT 会因没有 refresh_token @@ -505,8 +506,29 @@ func (s *OpenAIGatewayService) ensureOpenAIAlphaSearchAuthMetadata(ctx context.C return nil } +// isOpenAIAlphaSearchEndpointUnsupported 识别「API key 上游没有实现 +// /v1/alpha/search 端点」的响应。404/405 不在通用 failover 状态集里(模型 +// 调用中的 404 通常是用户请求问题),但对这个独立工具端点而言,它几乎只 +// 意味着所选上游(官方平台或第三方中转)不提供该端点——应换号重试,而 +// 不是把 404 透传给客户端,否则混合分组里 OAuth 账号明明可以承接搜索, +// 请求却可能死在先被选中的 API key 账号上。 +func isOpenAIAlphaSearchEndpointUnsupported(account *Account, statusCode int) bool { + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed +} + func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool { - return statusCode != http.StatusUnauthorized + switch statusCode { + case http.StatusUnauthorized, http.StatusNotFound, http.StatusMethodNotAllowed: + // 401:工具端点的 access enforcement 不代表凭据全局失效; + // 404/405:端点不存在只说明该上游不支持独立搜索,账号本身健康。 + // 两类都只换号,不写账号错误状态。 + return false + default: + return true + } } func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) { diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index 6b01356016..e31fd9e01d 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -393,8 +393,101 @@ func TestForwardAlphaSearchPATResponsesFallbackUnauthorizedDoesNotMarkAccountErr require.False(t, c.Writer.Written()) } +// API key 上游(官方平台或第三方中转)不提供 /v1/alpha/search 时返回的 +// 404/405 必须触发换号而不是把错误透传给客户端:混合分组里 OAuth 账号可以 +// 承接搜索,请求不能死在先被选中的 API key 账号上。端点缺失也不能写账号 +// 错误状态——账号本身是健康的。 +func TestForwardAlphaSearchAPIKeyEndpointNotFoundFailsOver(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Not Found"}}`)), + }} + repo := &alphaSearchAccountStateRepo{} + cfg := &config.Config{} + service := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: upstream, + accountRepo: repo, + rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil), + } + account := &Account{ + ID: 9, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://relay.example", + }, + } + + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusNotFound, failoverErr.StatusCode) + require.Zero(t, repo.setErrorCalls) + require.Empty(t, repo.lastError) + require.False(t, c.Writer.Written()) + require.Empty(t, recorder.Body.String()) +} + +// OAuth 账号的 chatgpt.com 端点固定存在,404 保持原有透传行为不变。 +func TestForwardAlphaSearchOAuthNotFoundPassesThrough(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body)) + + upstreamBody := `{"detail":"Not Found"}` + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 10, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-account", + }, + } + + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) + + require.NoError(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusNotFound, recorder.Code) + require.JSONEq(t, upstreamBody, recorder.Body.String()) +} + func TestShouldApplyOpenAIAlphaSearchAccountErrorSideEffects(t *testing.T) { require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusUnauthorized)) + require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusNotFound)) + require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusMethodNotAllowed)) require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusForbidden)) require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusTooManyRequests)) } + +func TestIsOpenAIAlphaSearchEndpointUnsupported(t *testing.T) { + apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + require.True(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusNotFound)) + require.True(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusMethodNotAllowed)) + require.False(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusBadRequest)) + require.False(t, isOpenAIAlphaSearchEndpointUnsupported(oauth, http.StatusNotFound)) + require.False(t, isOpenAIAlphaSearchEndpointUnsupported(nil, http.StatusNotFound)) +} diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 8c18c9e5eb..c093029332 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -61,6 +61,12 @@ func (s *OpenAIGatewayService) forwardGrokResponses( if err != nil { return nil, fmt.Errorf("apply grok prompt cache identity: %w", err) } + // Free OAuth + client function tools: reuse Messages mixed-tools cache route + // (append web_search/x_search so xAI does not force non-cacheable build-free). + patchedBody, err = applyGrokFreeMessagesFunctionToolCacheRoute(patchedBody, body, account, cacheIdentity) + if err != nil { + return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err) + } token, _, err := s.getRequestCredential(ctx, c, account) if err != nil { diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index dac9e975e1..9b6436e436 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -102,8 +102,8 @@ func isGrokRequestContext(c *gin.Context) bool { // Free OAuth requests without native search tools are routed by xAI to the // non-cacheable build-free model. For otherwise tool-free requests, add the // native tools with tool_choice=none: this selects the cache-capable tier -// without allowing an actual search. Explicit client tools are handled by the -// narrower Messages-only mixed-tools policy below. +// without allowing an actual search. Explicit client function tools are handled by +// applyGrokFreeMessagesFunctionToolCacheRoute (Messages bridge and native Responses). func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity string, injectFreeTierTools bool) ([]byte, error) { identity = strings.TrimSpace(identity) if identity == "" { @@ -263,17 +263,35 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) { toolType := strings.TrimSpace(tool.Get("type").String()) switch toolType { case "function": - if !tool.IsObject() || strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() { + name := strings.TrimSpace(tool.Get("name").String()) + if !tool.IsObject() || name == "" || tool.Get("function").Exists() { return body, nil } + // Grok Build may declare search as function tools. Convert to native + // entries so Free OAuth stays cache-capable without duplicate names. + if name == "web_search" || name == "x_search" { + if present[name] { + continue + } + raw, err := json.Marshal(map[string]string{"type": name}) + if err != nil { + return nil, err + } + merged = append(merged, raw) + present[name] = true + continue + } hasFunction = true + merged = append(merged, json.RawMessage(tool.Raw)) case "web_search", "x_search": - // Native tools may already be present when this helper is retried. + if present[toolType] { + continue + } + merged = append(merged, json.RawMessage(tool.Raw)) + present[toolType] = true default: return body, nil } - merged = append(merged, json.RawMessage(tool.Raw)) - present[toolType] = true } if !hasFunction { return body, nil diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index d3579e44de..82f9c3addc 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -475,7 +475,7 @@ func TestAccountSupportsOpenAIImageCapability_EmptyRequirementDoesNotRejectGrok( } func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) { - t.Run("OpenAI APIKey 默认兼容 chat 和 embeddings", func(t *testing.T) { + t.Run("OpenAI APIKey 默认兼容 chat、embeddings 和 alpha search", func(t *testing.T) { account := &Account{ Platform: PlatformOpenAI, Type: AccountTypeAPIKey, @@ -483,6 +483,7 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) { require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions)) require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings)) + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch)) }) t.Run("OpenAI OAuth 默认仅兼容 chat", func(t *testing.T) { @@ -496,7 +497,9 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) { require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings)) }) - t.Run("alpha search 仅允许 OpenAI OAuth/PAT 类账号", func(t *testing.T) { + t.Run("alpha search 允许 OpenAI OAuth/PAT 与 APIKey 账号,拒绝 Grok", func(t *testing.T) { + // OAuth/PAT 走 chatgpt.com Codex 端点,APIKey 走 {base_url}/v1/alpha/search, + // 两类都能承接独立搜索(APIKey 被排除曾导致纯 APIKey 分组搜索失效的回归)。 apiKey := &Account{ Platform: PlatformOpenAI, Type: AccountTypeAPIKey, @@ -505,9 +508,14 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, } + grok := &Account{ + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + } - require.False(t, apiKey.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch)) + require.True(t, apiKey.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch)) require.True(t, oauth.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch)) + require.False(t, grok.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch)) }) t.Run("显式列表支持同时声明 chat 和 embeddings", func(t *testing.T) { @@ -533,7 +541,8 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) { } require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions)) - require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch)) + // chat 能力隐含放行 alpha search(OAuth/APIKey 语义一致)。 + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch)) require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings)) }) diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 09b3dc0628..cc7b307982 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -201,6 +201,11 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( releaseUpstreamCtx() return nil, fmt.Errorf("apply grok prompt cache identity: %w", err) } + body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, grokIntentSourceBody, account, grokCacheIdentity) + if err != nil { + releaseUpstreamCtx() + return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err) + } upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, body, token, grokCacheIdentity, s.cfg) } else { upstreamReq, err = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token) diff --git a/frontend/src/components/payment/StripePaymentInline.vue b/frontend/src/components/payment/StripePaymentInline.vue index 65f5bb18c7..48b0748e74 100644 --- a/frontend/src/components/payment/StripePaymentInline.vue +++ b/frontend/src/components/payment/StripePaymentInline.vue @@ -111,7 +111,7 @@ let elementsInstance: StripeElements | null = null onMounted(async () => { try { - const { loadStripe } = await import('@stripe/stripe-js') + const { loadStripe } = await import('@stripe/stripe-js/pure') const stripe = await loadStripe(props.publishableKey) if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return } diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 1d38b2f1c8..f60dca15ec 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -868,6 +868,8 @@ export default { refreshTokenAuth: 'Manual RT Input', refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line', + mobileRefreshTokenAuth: 'Manual Mobile RT Input', + accessTokenAuth: 'Manual AT Input', codexSessionAuth: 'Codex OAuth auth.json / AT Import', codexSessionDesc: 'Paste a Codex OAuth auth.json or an accessToken. Accounts use the step 1 settings.', codexSessionInputLabel: 'Codex OAuth auth.json or accessToken', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index d6bfb88e07..f177122e7f 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -146,10 +146,11 @@ export default { }, apiKeyAcl: { title: 'API Key IP Access Control', - description: 'Choose which client IP is used by API Key allowlists and denylists', + description: + 'Choose which client IP is used by API Key allowlists/denylists, admin audit logs, and session IP/UA binding', trustForwardedIp: 'Trust forwarded client IP', trustForwardedIpHint: - 'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists and denylists use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records.' + 'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists/denylists, admin audit logs, and session IP/UA binding use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records. Toggling this switch changes the IP fingerprint of existing sessions; with session binding enabled they must sign in again.' }, linuxdo: { title: 'LinuxDo Connect Login', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index f72582c54b..0a6c7392da 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -955,6 +955,8 @@ export default { refreshTokenAuth: '手动输入 RT', refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。', refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个', + mobileRefreshTokenAuth: '手动输入 Mobile RT', + accessTokenAuth: '手动输入 AT', codexSessionAuth: 'Codex OAuth auth.json / AT 导入', codexSessionDesc: '粘贴 Codex OAuth auth.json 或 accessToken,按第一步配置创建账号。', codexSessionInputLabel: 'Codex OAuth auth.json 或 accessToken', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index b62ffb5085..3b4b568615 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -146,10 +146,10 @@ export default { }, apiKeyAcl: { title: 'API Key IP 访问控制', - description: '控制 API Key 白名单和黑名单使用哪个客户端 IP 判断', + description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断', trustForwardedIp: '信任反代传递的客户端 IP', trustForwardedIpHint: - '默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。' + '默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单、操作审计日志与会话 IP/UA 绑定会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。切换本开关会改变已登录会话的 IP 指纹,开启会话绑定时现有会话需重新登录。' }, linuxdo: { title: 'LinuxDo Connect 登录', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 7b3e1c3f64..fce956e8e2 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -252,7 +252,24 @@