mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:18:25 +08:00
fix(codex): 保留级联中继的客户端指纹
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user