Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ deploy/docker-compose.override.yml
vite.config.js
docs/*
!docs/openai-ws-sse-acceleration.md
!docs/openai-outbound-identity.md
!docs/PAYMENT.md
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
Expand Down
5 changes: 3 additions & 2 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1094,9 +1094,10 @@ type GatewayConfig struct {
ForceCodexCLI bool `mapstructure:"force_codex_cli"`
// DisableCodexIdentityEnforcement: 关闭「强制统一 Codex 出站身份」。上游 /backend-api/codex
// 在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到 HTTP 200 + 流内
// server_is_overloaded,该次请求失败。默认强制统一出口:所有 OAuth 出站的
// server_is_overloaded,该次请求失败。默认强制统一出口:OAuth 与 OpenAI API Key 出站的
// User-Agent / originator / version 都改写为网关规范身份,确保没有请求带着第三方或陈旧身份
// 出站。置 true 后退回「仅按最终 User-Agent 配对 originator」的收口语义,供上游策略变动时回滚。
// 出站。API Key 的供应商专用头与显式账号 header_overrides 保留更高优先级。
// 置 true 后,OAuth 退回「按最终 UA 配对 originator」,API Key 恢复原有头透传,供回滚使用。
//
// 取反义命名是为了让零值安全:该开关会发布为进程级快照,未经 viper 加载而手工构造的
// Config(测试、工具)其零值必须落在「强制统一开启」这一侧,否则会静默丢掉这层保护。
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/service/account_test_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
}

// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
applyOpenAIAPIKeyIdentityHeaders(req.Header, credentialAccount, credentialAccount.GetOpenAIUserAgent())
credentialAccount.ApplyHeaderOverrides(req.Header)

// Get proxy URL
Expand Down Expand Up @@ -2301,6 +2302,8 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection(
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+authToken)

applyOpenAIAPIKeyIdentityHeaders(req.Header, account, account.GetOpenAIUserAgent())

// 官方 OpenCode / Command Code 上游收敛为规范客户端 UA,与真实转发路径一致。
applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header)

Expand Down Expand Up @@ -2439,6 +2442,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
}

// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
applyOpenAIAPIKeyIdentityHeaders(req.Header, credentialAccount, credentialAccount.GetOpenAIUserAgent())
account.ApplyHeaderOverrides(req.Header)

proxyURL := ""
Expand Down Expand Up @@ -3242,6 +3246,7 @@ func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.C
req.Header.Set("Authorization", "Bearer "+authToken)

// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
applyOpenAIAPIKeyIdentityHeaders(req.Header, account, account.GetOpenAIUserAgent())
account.ApplyHeaderOverrides(req.Header)

proxyURL := ""
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/service/openai_alpha_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c
}
applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID)
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
applyOpenAIAPIKeyIdentityHeaders(req.Header, account, s.codexIdentityOverrideUA(account))
account.ApplyHeaderOverrides(req.Header)
return req, nil
}
Expand Down Expand Up @@ -424,6 +425,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
}

applyOpenAIAPIKeyIdentityHeaders(req.Header, account, s.codexIdentityOverrideUA(account))
account.ApplyHeaderOverrides(req.Header)
stripOpenAIAlphaSearchResponsesHeaders(req.Header)
return req, nil
Expand Down
150 changes: 150 additions & 0 deletions backend/internal/service/openai_apikey_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//go:build unit

package service

import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

// Exercise the real builders, including their header copy/override ordering.
// No parallel subtests: the canonical version resolver is process-wide.
func TestOpenAIAPIKeyOutboundIdentity(t *testing.T) {
gin.SetMode(gin.TestMode)
SetCodexCanonicalUserAgentResolver(func() string {
return openai.CodexDefaultOriginator + "/0.200.1" + codexCLIUserAgentSuffix
})
t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
svc := &OpenAIGatewayService{}
body := []byte(`{"model":"gpt-6-sol","input":[],"stream":true}`)
builders := map[string]func(*gin.Context, *Account) (http.Header, error){
"responses": func(c *gin.Context, a *Account) (http.Header, error) {
r, err := svc.buildUpstreamRequest(c.Request.Context(), c, a, body, "test-token", true, "", false)
if err != nil {
return nil, err
}
return r.Header, nil
},
"passthrough": func(c *gin.Context, a *Account) (http.Header, error) {
r, err := svc.buildUpstreamRequestOpenAIPassthrough(c.Request.Context(), c, a, body, "test-token")
if err != nil {
return nil, err
}
return r.Header, nil
},
"input_tokens": func(c *gin.Context, a *Account) (http.Header, error) {
r, err := svc.buildInputTokensUpstreamRequest(c.Request.Context(), c, a, body, "test-token")
if err != nil {
return nil, err
}
return r.Header, nil
},
"images": func(c *gin.Context, a *Account) (http.Header, error) {
r, err := svc.buildOpenAIImagesRequest(c.Request.Context(), c, a, body, "application/json", "test-token", openAIImagesGenerationsEndpoint)
if err != nil {
return nil, err
}
return r.Header, nil
},
"websocket": func(c *gin.Context, a *Account) (http.Header, error) {
h, _, err := svc.buildOpenAIWSHeaders(c.Request.Context(), c, a, "test-token",
OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2},
false, "", "", "", "gpt-6-sol", "")
return h, err
},
}
for name, build := range builders {
for _, ua := range []string{"", "Go-http-client/2.0", "python-httpx/0.28.0", "Mozilla/5.0", "codex_cli_rs/0.125.0"} {
t.Run(name+"/"+ua, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", ua)
c.Request.Header.Set("Originator", "untrusted-client")
c.Request.Header.Set("Version", "0.1.0")
before := c.Request.Header.Clone()
h, err := build(c, &Account{ID: 991, Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
require.NoError(t, err)
require.Equal(t, openai.CodexDefaultOriginator+"/0.200.1"+codexCLIUserAgentSuffix, h.Get("User-Agent"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("Originator"))
require.Equal(t, "0.200.1", h.Get("Version"))
require.Equal(t, "Bearer test-token", h.Get("Authorization"))
require.Equal(t, before, c.Request.Header, "inbound audit headers must remain intact")
})
}
}
}

func TestOpenAIAPIKeyChatTestOutboundIdentity(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/pelican-test", nil)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": {"text/event-stream"}},
Body: io.NopCloser(strings.NewReader("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: [DONE]\n\n")),
}}
svc := &AccountTestService{httpUpstream: upstream}
account := &Account{ID: 992, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
require.NoError(t, svc.testOpenAIChatCompletionsConnection(c, account, "gpt-6-sol", "hi", "https://upstream.example/v1", "test-token"))
require.Equal(t, CodexCanonicalUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("Originator"))
require.Equal(t, CodexCanonicalClientVersion(), upstream.lastReq.Header.Get("Version"))
}

func TestOpenAIAPIKeyChatForwardOutboundIdentity(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
c.Request.Header.Set("User-Agent", "Go-http-client/2.0")
upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("{}"))}}
svc := &OpenAIGatewayService{httpUpstream: upstream}
account := &Account{ID: 993, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
resp, err := svc.sendCCUpstreamRequest(context.Background(), c, account, "https://upstream.example/v1/chat/completions", []byte(`{"model":"gpt-6-sol","messages":[]}`), false, "test-token", "", "")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, CodexCanonicalUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("Originator"))
require.Equal(t, CodexCanonicalClientVersion(), upstream.lastReq.Header.Get("Version"))
}

func TestOpenAIAPIKeyIdentityPreservesOverridesAndOptOut(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "Go-http-client/2.0")
for _, tc := range []struct {
name string
account *Account
force bool
disabled bool
wantUA string
}{
{"account_codex_version_refreshed", &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"user_agent": "codex_vscode/0.125.0 (Linux; x86_64) vscode"}}, false, false, "codex_vscode/" + codexCLIVersion + " (Linux; x86_64) vscode"},
{"force_cli", &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"user_agent": "codex_vscode/0.125.0 (Linux; x86_64) vscode"}}, true, false, codexCLIUserAgent},
{"explicit_override", &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{credKeyHeaderOverrideEnabled: true, credKeyHeaderOverrides: map[string]any{"user-agent": "custom-client/1.0", "originator": "custom-client", "version": "1.0"}}}, false, false, "custom-client/1.0"},
{"disabled", &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false, true, "Go-http-client/2.0"},
{"other_platform", &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}, false, false, "Go-http-client/2.0"},
} {
t.Run(tc.name, func(t *testing.T) {
SetCodexIdentityEnforcementEnabled(!tc.disabled)
t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: tc.force}}}
r, err := svc.buildUpstreamRequest(c.Request.Context(), c, tc.account, []byte(`{"model":"gpt-6-sol"}`), "test-token", true, "", false)
require.NoError(t, err)
require.Equal(t, tc.wantUA, r.Header.Get("User-Agent"))
if tc.name == "explicit_override" {
require.Equal(t, "custom-client", getHeaderRaw(r.Header, "originator"))
require.Equal(t, "1.0", getHeaderRaw(r.Header, "version"))
}
})
}
}
1 change: 1 addition & 0 deletions backend/internal/service/openai_apikey_responses_probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
applyOpenAICodexProbeHeaders(req.Header)

// 账号级请求头覆写:能力探测与真实转发保持一致的最终头
applyOpenAIAPIKeyIdentityHeaders(req.Header, account, account.GetOpenAIUserAgent())
account.ApplyHeaderOverrides(req.Header)

proxyURL := ""
Expand Down
24 changes: 24 additions & 0 deletions backend/internal/service/openai_codex_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,30 @@ func codexClientVersionFromUA(ua string) string {
return version
}

// applyOpenAIAPIKeyIdentityHeaders gives every OpenAI API-key account the same
// canonical client identity as OAuth forwarding. Incoming library/browser UAs
// do not select the outbound identity. Version follows the existing resolver.
// Apply before provider-specific headers and explicit account header overrides;
// those remain escape hatches for compatible upstreams with a different contract.
// Unlike a Codex probe, this does not add beta flags or session identifiers.
func applyOpenAIAPIKeyIdentityHeaders(h http.Header, account *Account, overrideUA string) {
if h == nil || account == nil || !account.IsOpenAIApiKey() || !codexIdentityEnforcement.Load() {
return
}
identity := resolveCodexOutboundIdentity(overrideUA)
// Remove noncanonical key variants too, so a copied lower-case header cannot
// coexist with the normalized identity on the wire.
for key := range h {
switch strings.ToLower(key) {
case "user-agent", "originator", "version":
delete(h, key)
}
}
h.Set("User-Agent", identity.userAgent)
h.Set("Originator", identity.originator)
h.Set("Version", identity.version)
}

// ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。
// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。
func ensureCodexIdentityHeaders(h http.Header) {
Expand Down
1 change: 1 addition & 0 deletions backend/internal/service/openai_embeddings.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
}

// 账号级请求头覆写(仅 openai api_key 账号启用时生效)
applyOpenAIAPIKeyIdentityHeaders(upstreamReq.Header, account, s.codexIdentityOverrideUA(account))
account.ApplyHeaderOverrides(upstreamReq.Header)

proxyURL := ""
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/service/openai_gateway_cc_pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
}
applyGrokCacheHeaders(upstreamReq.Header, grokCacheIdentity)
}
applyOpenAIAPIKeyIdentityHeaders(upstreamReq.Header, account, s.codexIdentityOverrideUA(account))

// 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库
// UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。
applyOpenCodeUpstreamUserAgent(account, targetURL, upstreamReq.Header)
Expand Down
1 change: 1 addition & 0 deletions backend/internal/service/openai_gateway_count_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ func (s *OpenAIGatewayService) buildInputTokensUpstreamRequest(
}

// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
applyOpenAIAPIKeyIdentityHeaders(req.Header, account, s.codexIdentityOverrideUA(account))
account.ApplyHeaderOverrides(req.Header)

return req, nil
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/service/openai_gateway_forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -1817,6 +1817,8 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
req.Header.Set("content-type", "application/json")
}

applyOpenAIAPIKeyIdentityHeaders(req.Header, account, s.codexIdentityOverrideUA(account))

// 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库
// UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。
applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header)
Expand All @@ -1841,6 +1843,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
// ForceCodexCLI 语义是「强制使用 Codex CLI 身份」,等价于使用网关规范身份,故返回空串;
// 该优先级与历史行为一致(ForceCodexCLI 在账号自定义 UA 之后生效)。
func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string {
if account == nil {
return ""
}
if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
return ""
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,11 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(
require.NotNil(t, result.ReasoningEffort)
require.Equal(t, "medium", *result.ReasoningEffort)
require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
require.Equal(t, "third-party-client/1.0.0", upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "opencode", upstream.lastReq.Header.Get("originator"))
require.Empty(t, upstream.lastReq.Header.Get("version"))
identity := resolveCodexOutboundIdentity("")
require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, identity.originator, upstream.lastReq.Header.Get("originator"))
require.Equal(t, identity.version, upstream.lastReq.Header.Get("version"))
require.Equal(t, "third-party-client/1.0.0", c.GetHeader("User-Agent"))
require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
require.Equal(t, "ok", gjson.Get(rec.Body.String(), "content.0.text").String())
}
2 changes: 2 additions & 0 deletions backend/internal/service/openai_gateway_passthrough.go
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
req.Header.Set("content-type", "application/json")
}

applyOpenAIAPIKeyIdentityHeaders(req.Header, account, s.codexIdentityOverrideUA(account))

// 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库
// UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。
applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header)
Expand Down
1 change: 1 addition & 0 deletions backend/internal/service/openai_images.go
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,7 @@ func (s *OpenAIGatewayService) buildOpenAIImagesRequest(
req.Header.Set("Content-Type", contentType)
}
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
applyOpenAIAPIKeyIdentityHeaders(req.Header, account, s.codexIdentityOverrideUA(account))
account.ApplyHeaderOverrides(req.Header)
return req, nil
}
Expand Down
5 changes: 4 additions & 1 deletion backend/internal/service/openai_oauth_passthrough_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2583,7 +2583,10 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd
require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists())
require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer sk-api-key", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "curl/8.0", upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, CodexCanonicalUserAgent(), upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator"))
require.Equal(t, CodexCanonicalClientVersion(), upstream.lastReq.Header.Get("version"))
require.Equal(t, "curl/8.0", c.GetHeader("User-Agent"))
require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features"))
require.Equal(t, "window-passthrough", upstream.lastReq.Header.Get("X-Codex-Window-ID"))
require.Equal(t, "installation-passthrough", upstream.lastReq.Header.Get("X-Codex-Installation-ID"))
Expand Down
1 change: 1 addition & 0 deletions backend/internal/service/openai_ws_forwarder_payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(

// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。
// 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。
applyOpenAIAPIKeyIdentityHeaders(headers, account, s.codexIdentityOverrideUA(account))
account.ApplyHeaderOverrides(headers)
setOpenAICodexRoutingHint(headers, account, routingModel, routingServiceTier)
logOpenAIRoutingDiagnostics(
Expand Down
Loading
Loading