From f323174d0745402b24599029e052a3d60cf03cf5 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sat, 21 Feb 2026 12:06:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(openai):=20=E4=BF=AE=E5=A4=8D=20codex=5Fcli?= =?UTF-8?q?=5Fonly=20=E8=AF=AF=E6=8B=A6=E6=88=AA=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20codex=20=E5=AE=B6=E6=97=8F=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 codex_cli_only 增加 originator 判定通道,避免仅依赖 User-Agent 误拦截 - 扩展官方客户端家族标识,补充 codex_chatgpt_desktop 等常见前缀 - 新增并更新单元测试与网关透传回归测试,覆盖 UA 与 originator 组合场景 Co-Authored-By: Claude Opus 4.6 --- backend/internal/pkg/openai/request.go | 54 +++++++++++++------ backend/internal/pkg/openai/request_test.go | 34 ++++++++++++ .../openai_client_restriction_detector.go | 11 ++++ ...openai_client_restriction_detector_test.go | 31 ++++++++--- .../service/openai_oauth_passthrough_test.go | 15 ++++-- 5 files changed, 116 insertions(+), 29 deletions(-) diff --git a/backend/internal/pkg/openai/request.go b/backend/internal/pkg/openai/request.go index d6ad5f17..c24d1273 100644 --- a/backend/internal/pkg/openai/request.go +++ b/backend/internal/pkg/openai/request.go @@ -15,41 +15,61 @@ var CodexOfficialClientUserAgentPrefixes = []string{ "codex_cli_rs/", "codex_vscode/", "codex_app/", + "codex_chatgpt_desktop/", + "codex_atlas/", + "codex_exec/", + "codex_sdk_ts/", + "codex ", +} + +// CodexOfficialClientOriginatorPrefixes matches Codex 官方客户端家族 originator 前缀。 +// 说明:OpenAI 官方 Codex 客户端并不只使用固定的 codex_app 标识。 +// 例如 codex_cli_rs、codex_vscode、codex_chatgpt_desktop、codex_atlas、codex_exec、codex_sdk_ts 等。 +var CodexOfficialClientOriginatorPrefixes = []string{ + "codex_", + "codex ", } // IsCodexCLIRequest checks if the User-Agent indicates a Codex CLI request func IsCodexCLIRequest(userAgent string) bool { - ua := strings.ToLower(strings.TrimSpace(userAgent)) + ua := normalizeCodexClientHeader(userAgent) if ua == "" { return false } - for _, prefix := range CodexCLIUserAgentPrefixes { - normalizedPrefix := strings.ToLower(strings.TrimSpace(prefix)) - if normalizedPrefix == "" { - continue - } - // 优先前缀匹配;若 UA 被网关/代理拼接为复合字符串时,退化为包含匹配。 - if strings.HasPrefix(ua, normalizedPrefix) || strings.Contains(ua, normalizedPrefix) { - return true - } - } - return false + return matchCodexClientHeaderPrefixes(ua, CodexCLIUserAgentPrefixes) } // IsCodexOfficialClientRequest checks if the User-Agent indicates a Codex 官方客户端请求。 // 与 IsCodexCLIRequest 解耦,避免影响历史兼容逻辑。 func IsCodexOfficialClientRequest(userAgent string) bool { - ua := strings.ToLower(strings.TrimSpace(userAgent)) + ua := normalizeCodexClientHeader(userAgent) if ua == "" { return false } - for _, prefix := range CodexOfficialClientUserAgentPrefixes { - normalizedPrefix := strings.ToLower(strings.TrimSpace(prefix)) + return matchCodexClientHeaderPrefixes(ua, CodexOfficialClientUserAgentPrefixes) +} + +// IsCodexOfficialClientOriginator checks if originator indicates a Codex 官方客户端请求。 +func IsCodexOfficialClientOriginator(originator string) bool { + v := normalizeCodexClientHeader(originator) + if v == "" { + return false + } + return matchCodexClientHeaderPrefixes(v, CodexOfficialClientOriginatorPrefixes) +} + +func normalizeCodexClientHeader(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func matchCodexClientHeaderPrefixes(value string, prefixes []string) bool { + for _, prefix := range prefixes { + normalizedPrefix := normalizeCodexClientHeader(prefix) if normalizedPrefix == "" { continue } - // 优先前缀匹配;若 UA 被网关/代理拼接为复合字符串时,退化为包含匹配。 - if strings.HasPrefix(ua, normalizedPrefix) || strings.Contains(ua, normalizedPrefix) { + // 优先前缀匹配;若 UA/Originator 被网关拼接为复合字符串时,退化为包含匹配。 + if strings.HasPrefix(value, normalizedPrefix) || strings.Contains(value, normalizedPrefix) { return true } } diff --git a/backend/internal/pkg/openai/request_test.go b/backend/internal/pkg/openai/request_test.go index d8c6a994..508bf561 100644 --- a/backend/internal/pkg/openai/request_test.go +++ b/backend/internal/pkg/openai/request_test.go @@ -36,6 +36,11 @@ func TestIsCodexOfficialClientRequest(t *testing.T) { {name: "codex_cli_rs 前缀", ua: "codex_cli_rs/0.98.0", want: true}, {name: "codex_vscode 前缀", ua: "codex_vscode/1.0.0", want: true}, {name: "codex_app 前缀", ua: "codex_app/0.1.0", want: true}, + {name: "codex_chatgpt_desktop 前缀", ua: "codex_chatgpt_desktop/1.0.0", want: true}, + {name: "codex_atlas 前缀", ua: "codex_atlas/1.0.0", want: true}, + {name: "codex_exec 前缀", ua: "codex_exec/0.1.0", want: true}, + {name: "codex_sdk_ts 前缀", ua: "codex_sdk_ts/0.1.0", want: true}, + {name: "Codex 桌面 UA", ua: "Codex Desktop/1.2.3", want: true}, {name: "复合 UA 包含 codex_app", ua: "Mozilla/5.0 codex_app/0.1.0", want: true}, {name: "大小写混合", ua: "Codex_VSCode/1.2.3", want: true}, {name: "非 codex", ua: "curl/8.0.1", want: false}, @@ -51,3 +56,32 @@ func TestIsCodexOfficialClientRequest(t *testing.T) { }) } } + +func TestIsCodexOfficialClientOriginator(t *testing.T) { + tests := []struct { + name string + originator string + want bool + }{ + {name: "codex_cli_rs", originator: "codex_cli_rs", want: true}, + {name: "codex_vscode", originator: "codex_vscode", want: true}, + {name: "codex_app", originator: "codex_app", want: true}, + {name: "codex_chatgpt_desktop", originator: "codex_chatgpt_desktop", want: true}, + {name: "codex_atlas", originator: "codex_atlas", want: true}, + {name: "codex_exec", originator: "codex_exec", want: true}, + {name: "codex_sdk_ts", originator: "codex_sdk_ts", want: true}, + {name: "Codex 前缀", originator: "Codex Desktop", want: true}, + {name: "空白包裹", originator: " codex_vscode ", want: true}, + {name: "非 codex", originator: "my_client", want: false}, + {name: "空字符串", originator: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsCodexOfficialClientOriginator(tt.originator) + if got != tt.want { + t.Fatalf("IsCodexOfficialClientOriginator(%q) = %v, want %v", tt.originator, got, tt.want) + } + }) + } +} diff --git a/backend/internal/service/openai_client_restriction_detector.go b/backend/internal/service/openai_client_restriction_detector.go index 1c3ef014..d1784e11 100644 --- a/backend/internal/service/openai_client_restriction_detector.go +++ b/backend/internal/service/openai_client_restriction_detector.go @@ -11,6 +11,8 @@ const ( CodexClientRestrictionReasonDisabled = "codex_cli_only_disabled" // CodexClientRestrictionReasonMatchedUA 表示请求命中官方客户端 UA 白名单。 CodexClientRestrictionReasonMatchedUA = "official_client_user_agent_matched" + // CodexClientRestrictionReasonMatchedOriginator 表示请求命中官方客户端 originator 白名单。 + CodexClientRestrictionReasonMatchedOriginator = "official_client_originator_matched" // CodexClientRestrictionReasonNotMatchedUA 表示请求未命中官方客户端 UA 白名单。 CodexClientRestrictionReasonNotMatchedUA = "official_client_user_agent_not_matched" // CodexClientRestrictionReasonForceCodexCLI 表示通过 ForceCodexCLI 配置兜底放行。 @@ -56,8 +58,10 @@ func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *A } userAgent := "" + originator := "" if c != nil { userAgent = c.GetHeader("User-Agent") + originator = c.GetHeader("originator") } if openai.IsCodexOfficialClientRequest(userAgent) { return CodexClientRestrictionDetectionResult{ @@ -66,6 +70,13 @@ func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *A Reason: CodexClientRestrictionReasonMatchedUA, } } + if openai.IsCodexOfficialClientOriginator(originator) { + return CodexClientRestrictionDetectionResult{ + Enabled: true, + Matched: true, + Reason: CodexClientRestrictionReasonMatchedOriginator, + } + } return CodexClientRestrictionDetectionResult{ Enabled: true, diff --git a/backend/internal/service/openai_client_restriction_detector_test.go b/backend/internal/service/openai_client_restriction_detector_test.go index 9c3cfa78..984b4ff6 100644 --- a/backend/internal/service/openai_client_restriction_detector_test.go +++ b/backend/internal/service/openai_client_restriction_detector_test.go @@ -10,13 +10,16 @@ import ( "github.com/stretchr/testify/require" ) -func newCodexDetectorTestContext(ua string) *gin.Context { +func newCodexDetectorTestContext(ua string, originator string) *gin.Context { rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) if ua != "" { c.Request.Header.Set("User-Agent", ua) } + if originator != "" { + c.Request.Header.Set("originator", originator) + } return c } @@ -27,7 +30,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { detector := NewOpenAICodexClientRestrictionDetector(nil) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{}} - result := detector.Detect(newCodexDetectorTestContext("curl/8.0"), account) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", ""), account) require.False(t, result.Enabled) require.False(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonDisabled, result.Reason) @@ -41,7 +44,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("codex_cli_rs/0.99.0"), account) + result := detector.Detect(newCodexDetectorTestContext("codex_cli_rs/0.99.0", ""), account) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason) @@ -55,7 +58,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("codex_vscode/1.0.0"), account) + result := detector.Detect(newCodexDetectorTestContext("codex_vscode/1.0.0", ""), account) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason) @@ -69,12 +72,26 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("codex_app/2.1.0"), account) + result := detector.Detect(newCodexDetectorTestContext("codex_app/2.1.0", ""), account) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason) }) + t.Run("开启后 originator 命中", func(t *testing.T) { + detector := NewOpenAICodexClientRestrictionDetector(nil) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only": true}, + } + + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "codex_chatgpt_desktop"), account) + require.True(t, result.Enabled) + require.True(t, result.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedOriginator, result.Reason) + }) + t.Run("开启后非官方客户端拒绝", func(t *testing.T) { detector := NewOpenAICodexClientRestrictionDetector(nil) account := &Account{ @@ -83,7 +100,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("curl/8.0"), account) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account) require.True(t, result.Enabled) require.False(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason) @@ -99,7 +116,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("curl/8.0"), account) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonForceCodexCLI, result.Reason) diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 49658d6d..7a996c26 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -555,12 +555,14 @@ func TestOpenAIGatewayService_CodexCLIOnly_AllowOfficialClientFamilies(t *testin gin.SetMode(gin.TestMode) tests := []struct { - name string - ua string + name string + ua string + originator string }{ - {name: "codex_cli_rs", ua: "codex_cli_rs/0.99.0"}, - {name: "codex_vscode", ua: "codex_vscode/1.0.0"}, - {name: "codex_app", ua: "codex_app/2.1.0"}, + {name: "codex_cli_rs", ua: "codex_cli_rs/0.99.0", originator: ""}, + {name: "codex_vscode", ua: "codex_vscode/1.0.0", originator: ""}, + {name: "codex_app", ua: "codex_app/2.1.0", originator: ""}, + {name: "originator_codex_chatgpt_desktop", ua: "curl/8.0", originator: "codex_chatgpt_desktop"}, } for _, tt := range tests { @@ -569,6 +571,9 @@ func TestOpenAIGatewayService_CodexCLIOnly_AllowOfficialClientFamilies(t *testin c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) c.Request.Header.Set("User-Agent", tt.ua) + if tt.originator != "" { + c.Request.Header.Set("originator", tt.originator) + } inputBody := []byte(`{"model":"gpt-5.2","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`)