fix(codex): 保留级联中继的客户端指纹

This commit is contained in:
Chenhao
2026-07-21 05:02:30 -07:00
committed by Birkhoff
parent 5a8d6c4e41
commit 8ffa2a31ff
12 changed files with 189 additions and 26 deletions
@@ -610,6 +610,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
// Set common headers
req.Header.Set("Content-Type", "application/json")
if !isOAuth {
applyOpenAICodexProbeHeaders(req.Header)
}
if credentialAccount.IsOpenAIAgentIdentity() {
authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, credentialAccount)
if authErr != nil {
@@ -937,10 +940,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
} else {
req.Header.Set("Authorization", "Bearer "+authToken)
}
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("User-Agent", codexCLIUserAgent)
req.Header.Set("Version", codexCLIVersion)
applyOpenAICodexProbeHeaders(req.Header)
probeSessionID := compactProbeSessionID(account.ID)
req.Header.Set("Session_ID", probeSessionID)
req.Header.Set("Conversation_ID", probeSessionID)
@@ -155,6 +155,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesCompact
require.NoError(t, err)
require.Equal(t, "https://example.com/v1/responses/compact", upstream.lastReq.URL.String())
requireOpenAICodexProbeHeaders(t, upstream.lastReq.Header)
require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String())
updates := <-updateCalls
require.Equal(t, true, updates["openai_compact_supported"])
@@ -424,6 +424,37 @@ func TestAccountTestService_OpenAI401SetsPermanentErrorOnly(t *testing.T) {
require.Nil(t, account.RateLimitResetAt)
}
func TestAccountTestService_OpenAIAPIKeyResponsesUsesCodexProbeHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := newTestContext()
resp := newJSONResponse(http.StatusOK, "")
resp.Body = io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\"}\n\n"))
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
svc := &AccountTestService{
httpUpstream: upstream,
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
}
account := &Account{
ID: 95,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "https://compat-upstream.example/v1",
},
Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: true},
}
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
require.NoError(t, err)
require.Len(t, upstream.requests, 1)
req := upstream.requests[0]
require.Equal(t, "https://compat-upstream.example/v1/responses", req.URL.String())
requireOpenAICodexProbeHeaders(t, req.Header)
}
func TestAccountTestService_OpenAIAPIKeyResponsesUnsupportedUsesChatCompletionsPath(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, recorder := newTestContext()
@@ -148,6 +148,7 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
applyOpenAICodexProbeHeaders(req.Header)
// 账号级请求头覆写:能力探测与真实转发保持一致的最终头
account.ApplyHeaderOverrides(req.Header)
@@ -1,12 +1,54 @@
package service
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/stretchr/testify/require"
)
func TestProbeOpenAIAPIKeyResponsesSupportUsesCodexProbeHeaders(t *testing.T) {
updateCalls := make(chan map[string]any, 1)
account := Account{
ID: 96,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "https://compat-upstream.example/v1",
},
}
repo := &snapshotUpdateAccountRepo{
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
updateExtraCalls: updateCalls,
}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"output":[{"type":"function_call","name":"probe_ping"}]}`)),
}}
svc := &AccountTestService{
accountRepo: repo,
httpUpstream: upstream,
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
}
svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "https://compat-upstream.example/v1/responses", upstream.lastReq.URL.String())
requireOpenAICodexProbeHeaders(t, upstream.lastReq.Header)
updates := <-updateCalls
require.Equal(t, true, updates[openai_compat.ExtraKeyResponsesSupported])
}
func TestDecideResponsesProbeSupport(t *testing.T) {
fnCall := []byte(`{"output":[{"type":"reasoning"},{"type":"function_call","name":"probe_ping"}]}`)
reasoningOnly := []byte(`{"output":[{"type":"reasoning"}]}`)
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/google/uuid"
)
// codexUpstreamMinVersion 上游 /backend-api/codex 接受的最低 version 头:
@@ -30,6 +31,15 @@ func ensureCodexIdentityHeaders(h http.Header) {
h.Set("OpenAI-Beta", "responses=experimental")
}
// applyOpenAICodexProbeHeaders 为合成探测请求补齐 Codex 身份和引擎指纹。
func applyOpenAICodexProbeHeaders(h http.Header) {
if h == nil {
return
}
ensureCodexIdentityHeaders(h)
h.Set("X-Codex-Window-ID", uuid.NewString())
}
// enforceCodexIdentityHeaders 收口 OAuth(ChatGPT 内部接口)出站请求的客户端身份头。
// 上游要求 originator 与 User-Agent 首段配套且为官方客户端标识,version 头(若携带)
// 不低于 0.144.0,任一不满足即 404(issue #3901)。以最终 User-Agent 为准推导配套
@@ -7,6 +7,15 @@ import (
"github.com/stretchr/testify/require"
)
func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) {
t.Helper()
require.Equal(t, codexCLIUserAgent, h.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", h.Get("Originator"))
require.Equal(t, codexCLIVersion, h.Get("Version"))
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
require.NotEmpty(t, h.Get("X-Codex-Window-ID"))
}
func TestEnsureCodexIdentityHeaders(t *testing.T) {
t.Run("补齐缺失身份头", func(t *testing.T) {
h := make(http.Header)
@@ -61,33 +61,37 @@ const (
// OpenAI allowed headers whitelist (for non-passthrough).
var openaiAllowedHeaders = map[string]bool{
"accept-language": true,
"content-type": true,
"conversation_id": true,
"user-agent": true,
"originator": true,
"session_id": true,
"x-codex-beta-features": true,
"x-codex-turn-state": true,
"x-codex-turn-metadata": true,
responsesLiteHeaderKey: true,
"accept-language": true,
"content-type": true,
"conversation_id": true,
"user-agent": true,
"originator": true,
"session_id": true,
"x-codex-beta-features": true,
"x-codex-installation-id": true,
"x-codex-turn-state": true,
"x-codex-turn-metadata": true,
"x-codex-window-id": true,
responsesLiteHeaderKey: true,
}
// OpenAI passthrough allowed headers whitelist.
// 透传模式下仅放行这些低风险请求头,避免将非标准/环境噪声头传给上游触发风控。
var openaiPassthroughAllowedHeaders = map[string]bool{
"accept": true,
"accept-language": true,
"content-type": true,
"conversation_id": true,
"openai-beta": true,
"user-agent": true,
"originator": true,
"session_id": true,
"x-codex-beta-features": true,
"x-codex-turn-state": true,
"x-codex-turn-metadata": true,
responsesLiteHeaderKey: true,
"accept": true,
"accept-language": true,
"content-type": true,
"conversation_id": true,
"openai-beta": true,
"user-agent": true,
"originator": true,
"session_id": true,
"x-codex-beta-features": true,
"x-codex-installation-id": true,
"x-codex-turn-state": true,
"x-codex-turn-metadata": true,
"x-codex-window-id": true,
responsesLiteHeaderKey: true,
}
// codex_cli_only 拒绝时记录的请求头白名单(仅用于诊断日志,不参与上游透传)
@@ -2504,6 +2504,32 @@ func TestOpenAIBuildUpstreamRequestPreservesCompactPathForAPIKeyBaseURL(t *testi
require.Equal(t, "https://example.com/v1/responses/compact", req.URL.String())
}
func TestOpenAIBuildUpstreamRequestPreservesCodexIdentityHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
c.Request.Header.Set("X-Codex-Window-ID", "window-http")
c.Request.Header.Set("X-Codex-Installation-ID", "installation-http")
c.Request.Header.Set("X-Test", "blocked")
body := []byte(`{"model":"gpt-5","input":"hello"}`)
svc := &OpenAIGatewayService{cfg: &config.Config{
Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
},
}}
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, body, "token", false, "", true)
require.NoError(t, err)
require.Equal(t, "window-http", req.Header.Get("X-Codex-Window-ID"))
require.Equal(t, "installation-http", req.Header.Get("X-Codex-Installation-ID"))
require.Empty(t, req.Header.Get("X-Test"))
require.True(t, openai.EvaluateEngineFingerprint(req.Header, body, openai.DefaultEngineFingerprintSignals))
}
func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -1960,6 +1960,8 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd
c.Request.Header.Set("User-Agent", "curl/8.0")
c.Request.Header.Set("X-Test", "keep")
c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2")
c.Request.Header.Set("X-Codex-Window-ID", "window-passthrough")
c.Request.Header.Set("X-Codex-Installation-ID", "installation-passthrough")
originalBody := []byte(`{"model":"gpt-5.2","stream":false,"service_tier":"flex","max_output_tokens":128,"input":[{"type":"text","text":"hi"}]}`)
resp := &http.Response{
@@ -1998,6 +2000,8 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd
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, "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"))
require.Empty(t, upstream.lastReq.Header.Get("X-Test"))
}
@@ -91,6 +91,11 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
headers.Add("x-codex-beta-features", value)
}
}
for _, name := range [...]string{"x-codex-window-id", "x-codex-installation-id"} {
if value := c.Request.Header.Get(name); strings.TrimSpace(value) != "" {
headers.Set(name, value)
}
}
}
// OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。
if account != nil && account.Type == AccountTypeOAuth {
@@ -391,6 +391,36 @@ func requestToJSONString(payload map[string]any) string {
return string(b)
}
func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
c.Request.Header.Set("X-Codex-Window-ID", "window-ws")
c.Request.Header.Set("X-Codex-Installation-ID", "installation-ws")
c.Request.Header.Set("X-Test", "blocked")
svc := &OpenAIGatewayService{}
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
headers, _, err := svc.buildOpenAIWSHeaders(
context.Background(),
c,
account,
"token",
OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2},
true,
"",
"",
"",
)
require.NoError(t, err)
require.Equal(t, "window-ws", headers.Get("X-Codex-Window-ID"))
require.Equal(t, "installation-ws", headers.Get("X-Codex-Installation-ID"))
require.Empty(t, headers.Get("X-Test"))
}
func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) {
require.NotPanics(t, func() {
logOpenAIWSBindResponseAccountWarn(1, 2, "resp_ok", nil)