Compare commits

...
12 Commits
Author SHA1 Message Date
Wesley Liddick 2a75d7d238 Merge pull request #4462 from Wei-Shaw/fix/unify-security-client-ip
fix(security): unify audit log & session binding client IP with API key ACL trust toggle
2026-07-17 09:42:43 +08:00
Wesley Liddick 1c3f2810af Merge pull request #4461 from yardbirds0/feat/account-homepage-link
feat: 支持从 API Key 账号名称跳转上游站点主页
2026-07-17 09:34:50 +08:00
shawandClaude 7c48f9a85f fix(security): unify audit log & session binding client IP with API key ACL trust toggle
Behind a reverse proxy (e.g. nginx with X-Real-IP), admin audit logs and
session IP/UA binding always recorded 127.0.0.1 because they hardcoded
the gin trusted_proxies chain, while API key IP restriction already
honored the "trust forwarded client IP" system setting.

- add ip.GetSecurityClientIP(c, trustForwarded) as the single source of
  truth for security-sensitive client IP selection; API key auth
  middlewares (main + google) refactored onto it with zero behavior change
- SessionBindingContext(cfg) now resolves the client IP via the same
  toggle and injects it into the request context; token issuance,
  binding enforcement and its mismatch audit record all read the
  injected value, so issue/verify can never diverge
- audit log middleware and audit-log clear trace record the same
  security client IP (middleware.SecurityClientIP), falling back to the
  trusted proxy chain when the injection is absent
- settings UI hint (zh/en) documents the broadened toggle scope and the
  one-time re-login after toggling while session binding is enabled

With the toggle off (default) behavior is byte-for-byte unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 09:33:18 +08:00
shawandClaude 980a46ffb0 fix(i18n): add missing OpenAI account entry labels
- admin.accounts.oauth.openai.mobileRefreshTokenAuth was referenced by
  OAuthAuthorizationFlow.vue since 9f8cffe88 (Mobile RT entry) but never
  added to zh/en locales, rendering the raw key in the add-account wizard
- admin.accounts.oauth.openai.accessTokenAuth has the same latent issue
  since 26060e702 (Sora AT import); currently hidden but fixed alongside

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 09:32:48 +08:00
yardbirds0 824f763a77 feat: 支持从 API Key 账号名称跳转上游站点 2026-07-17 09:15:32 +08:00
Wesley Liddick 9b3c96dc68 Merge pull request #4451 from rurucxk/fix/grok-free-responses-function-tool-cache
fix(grok): enable free prompt cache for Responses function tools
2026-07-17 09:04:47 +08:00
Wesley Liddick 52f2bc69fb Merge pull request #4450 from wucm667/fix/issue-4442-lazy-stripe-loader
fix(frontend): lazy-load Stripe payment dependency
2026-07-17 09:04:33 +08:00
Wesley Liddick 067cf8b46e Merge pull request #4459 from Wei-Shaw/fix/openai-alpha-search-apikey-scheduling
fix(openai): restore APIKey account scheduling for alpha/search
2026-07-17 09:04:17 +08:00
shaw d2b080e88b fix(openai): restore APIKey account scheduling for alpha/search
PR #4394 (776f3f0de) 在新增 alpha_search 调度能力时加入了 OAuth-only
门控,把 APIKey 账号从 /alpha/search 候选池整体剔除;但转发层自
52071d391 起就支持 APIKey 走 {base_url}/v1/alpha/search,门控注释中
「API key 会被发往 chatgpt.com 导致 401」与实际路由不符。结果是纯
APIKey 分组自 v0.1.157 起独立搜索在选号阶段即失败(无可用账号)。

修复:
- SupportsOpenAIEndpointCapability 的 alpha_search 门控放行
  AccountTypeAPIKey(OAuth/APIKey 均可,Grok 等仍拒绝);显式能力集
  语义不变,chat_completions 继续隐含放行 alpha_search。
- ForwardAlphaSearch 对 APIKey 账号的 404/405 按端点级 failover 处理
  (换号、不写账号错误状态),避免混合分组里请求死在不支持该端点的
  APIKey 上游;OAuth 账号 404 透传行为保持不变。
- 更新固化旧门控行为的用例,并在调度层新增 APIKey 放行回归锁。
2026-07-17 08:44:23 +08:00
rurucxk 29e39b96e5 fix(grok): enable free prompt cache for Responses function tools
Reuse applyGrokFreeMessagesFunctionToolCacheRoute on native /v1/responses
and the Grok WS HTTP bridge so Free OAuth requests with client function
tools get the same mixed-tools cache route as the Messages bridge
(append/convert web_search and x_search).

Also dedupe: Grok Build already declares function tools named web_search,
so naive append caused "Duplicate tool names: web_search". Convert those
function entries to native tool types and skip duplicates.

Only Free OAuth accounts (isKnownGrokFreeAccount); paid/unknown unchanged.
2026-07-17 00:23:19 +08:00
wucm667 08e994ad86 fix(frontend): lazy-load Stripe payment dependency 2026-07-16 23:21:58 +08:00
github-actions[bot] bc2244c83f chore: sync VERSION to 0.1.158 [skip ci] 2026-07-16 12:37:21 +00:00
30 changed files with 521 additions and 53 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.157
0.1.158
@@ -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,
}
+11
View File
@@ -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)
+30
View File
@@ -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())
})
}
}
@@ -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 == "" {
@@ -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 == "" {
@@ -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,
@@ -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,
})
@@ -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)
}
+3 -2
View File
@@ -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 {
+5 -4
View File
@@ -1425,10 +1425,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:
@@ -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()
@@ -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) {
@@ -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))
}
@@ -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 {
@@ -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
+13 -4
View File
@@ -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))
})
@@ -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)
@@ -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 }
@@ -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',
@@ -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',
@@ -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',
@@ -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 登录',
+25 -1
View File
@@ -252,7 +252,24 @@
</template>
<template #cell-name="{ row, value }">
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<HelpTooltip
v-if="accountHomepageUrl(row)"
:content="accountHomepageUrl(row)"
width-class="w-max max-w-sm break-all"
class="-ml-1 self-start"
>
<template #trigger>
<a
:href="accountHomepageUrl(row)"
target="_blank"
rel="noopener noreferrer"
class="border-b border-dotted border-gray-300 font-medium text-gray-900 dark:border-gray-600 dark:text-white"
>
{{ value }}
</a>
</template>
</HelpTooltip>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<span
v-if="accountDisplayEmail(row)"
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
@@ -523,6 +540,7 @@ import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
import { extractApiErrorMessage } from '@/utils/apiError'
import { sanitizeUrl } from '@/utils/url'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSettings, UpstreamBillingProbeSnapshot } from '@/types'
const { t } = useI18n()
@@ -1295,6 +1313,12 @@ function accountDisplayEmail(row: any): string {
return row.extra?.email_address || row.extra?.email || row.credentials?.email || row.parent_email || ''
}
function accountHomepageUrl(row: Account): string {
if (row.type !== 'apikey' || typeof row.credentials?.base_url !== 'string') return ''
const baseUrl = sanitizeUrl(row.credentials.base_url)
return baseUrl ? new URL(baseUrl).origin : ''
}
type OpenAICompactBadgeState = 'active' | 'blocked' | 'auto'
function getOpenAICompactState(row: any): OpenAICompactBadgeState | null {
@@ -5,6 +5,7 @@ import AccountsView from '../AccountsView.vue'
import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
// 外审 F2:AccountActionMenu emit 'create-spark-shadow',但 AccountsView 此前未监听,
// 导致按钮点击无效。本测试通过真实组件引用 emit 该事件,断言父页面接线调用 API。
@@ -207,7 +208,7 @@ describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
})
})
// Task 6: 影子行 parent_* OR 兜底展示
// 账号行展示
const mountViewWithRow = () =>
mount(AccountsView, {
global: {
@@ -255,7 +256,7 @@ const mountViewWithRow = () =>
}
})
describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
describe('admin AccountsView — 账号行展示', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, duplicateAccount, createSparkShadow, showSuccess, showError]) {
@@ -304,6 +305,47 @@ describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
wrapper.unmount()
})
it('仅将具有安全 base_url 的 API Key 账号名称链接到站点主页', async () => {
listAccounts.mockResolvedValue({
items: [
{ id: 101, name: 'relay-account', platform: 'openai', type: 'apikey', credentials: { base_url: 'https://relay.example.com/api/v1/' } },
{ id: 102, name: 'oauth-account', platform: 'openai', type: 'oauth', credentials: { base_url: 'https://oauth.example.com/v1' } },
{ id: 103, name: 'invalid-url', platform: 'openai', type: 'apikey', credentials: { base_url: 'javascript:alert(1)' } },
],
total: 3,
page: 1,
page_size: 20,
pages: 1,
})
const wrapper = mountViewWithRow()
await flushPromises()
const links = wrapper.findAll('a')
expect(links).toHaveLength(1)
const [link] = links
expect(link.text()).toBe('relay-account')
expect(link.attributes()).toMatchObject({
href: 'https://relay.example.com',
target: '_blank',
rel: 'noopener noreferrer',
})
expect(link.classes()).toEqual(expect.arrayContaining([
'border-dotted',
'text-gray-900',
'dark:text-white',
]))
expect(link.classes()).not.toContain('text-primary-600')
const tooltip = wrapper.findComponent(HelpTooltip)
expect(tooltip.props('content')).toBe('https://relay.example.com')
expect(tooltip.props('widthClass')).toBe('w-max max-w-sm break-all')
expect(tooltip.classes()).toEqual(expect.arrayContaining(['self-start']))
expect(wrapper.text()).toContain('oauth-account')
expect(wrapper.text()).toContain('invalid-url')
wrapper.unmount()
})
it('passes fresh Grok billing and quota snapshots before stale credential fallbacks', async () => {
const grokAccounts = [
{
@@ -165,7 +165,7 @@ onMounted(async () => {
const publishableKey = paymentStore.config?.stripe_publishable_key
if (!publishableKey) { initError.value = t('payment.stripeNotConfigured'); return }
const { loadStripe } = await import('@stripe/stripe-js')
const { loadStripe } = await import('@stripe/stripe-js/pure')
const stripe = await loadStripe(publishableKey)
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
+1 -1
View File
@@ -137,7 +137,7 @@ async function initStripe(clientSecret: string, publishableKey: string) {
return
}
try {
const { loadStripe } = await import('@stripe/stripe-js')
const { loadStripe } = await import('@stripe/stripe-js/pure')
const stripe = await loadStripe(publishableKey)
if (!stripe) { error.value = t('payment.stripeLoadFailed'); return }
@@ -56,7 +56,7 @@ vi.mock('@/api/payment', () => ({
},
}))
vi.mock('@stripe/stripe-js', () => ({
vi.mock('@stripe/stripe-js/pure', () => ({
loadStripe,
}))
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const frontendRoot = resolve(__dirname, '../../../..')
const stripeConsumers = [
'src/views/user/StripePaymentView.vue',
'src/views/user/StripePopupView.vue',
'src/components/payment/StripePaymentInline.vue',
]
function readFrontendFile(path: string): string {
return readFileSync(resolve(frontendRoot, path), 'utf8')
}
describe('Stripe lazy-loading contract', () => {
it.each(stripeConsumers)('%s uses the side-effect-free Stripe loader', (path) => {
const source = readFrontendFile(path)
expect(source).toContain("await import('@stripe/stripe-js/pure')")
expect(source).not.toMatch(/await import\(['"]@stripe\/stripe-js['"]\)/)
})
it('keeps Stripe out of the shared vendor chunk', () => {
const viteConfig = readFrontendFile('vite.config.ts')
const stripeRule = viteConfig.indexOf("id.includes('/@stripe/stripe-js/')")
const miscFallback = viteConfig.indexOf("return 'vendor-misc'")
expect(stripeRule).toBeGreaterThan(-1)
expect(viteConfig.slice(stripeRule, miscFallback)).toContain("return 'vendor-stripe'")
expect(stripeRule).toBeLessThan(miscFallback)
})
})
+5
View File
@@ -96,6 +96,11 @@ export default defineConfig(({ mode }) => {
return 'vendor-i18n'
}
// Stripe 仅在支付流程中按需加载,避免进入首页公共依赖。
if (id.includes('/@stripe/stripe-js/')) {
return 'vendor-stripe'
}
// 其他小型第三方库合并
return 'vendor-misc'
}