diff --git a/backend/internal/service/openai_client_restriction_detector.go b/backend/internal/service/openai_client_restriction_detector.go index abca88ce66..8a8097c879 100644 --- a/backend/internal/service/openai_client_restriction_detector.go +++ b/backend/internal/service/openai_client_restriction_detector.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "net/http" "github.com/Wei-Shaw/sub2api/internal/config" @@ -8,6 +9,11 @@ import ( "github.com/gin-gonic/gin" ) +// CodexOfficialClientsOnlyMessage 是 codex_cli_only 拒绝时面向客户端的通用兜底文案。 +// 仅当拒绝原因不是「可解析版本但越界」(VersionTooLow/VersionTooHigh)时使用: +// 未命中官方/黑名单/缺指纹/版本无法识别都沿用这句(避免向伪装客户端泄露门控细节)。 +const CodexOfficialClientsOnlyMessage = "This account only allows Codex official clients" + const ( // CodexClientRestrictionReasonDisabled 表示账号未开启 codex_cli_only。 CodexClientRestrictionReasonDisabled = "codex_cli_only_disabled" @@ -51,6 +57,13 @@ type CodexClientRestrictionDetectionResult struct { Enabled bool Matched bool Reason string + // DetectedVersion 是从官方 UA 解析出的 Codex 引擎版本;仅在版本门拒绝 + // (VersionTooLow / VersionTooHigh) 时填充,供面向客户端的差异化文案使用。 + DetectedVersion string + // MinCodexVersion 是触发 VersionTooLow 时的最低要求版本(来自策略快照)。 + MinCodexVersion string + // MaxCodexVersion 是触发 VersionTooHigh 时的最高允许版本(来自策略快照)。 + MaxCodexVersion string } // CodexClientRestrictionDetector 定义 codex_cli_only 统一检测入口。 @@ -127,10 +140,22 @@ func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *A return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionUndetectable} } if policy.MinCodexVersion != "" && CompareVersions(ver, policy.MinCodexVersion) < 0 { - return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionTooLow} + return CodexClientRestrictionDetectionResult{ + Enabled: true, + Matched: false, + Reason: CodexClientRestrictionReasonVersionTooLow, + DetectedVersion: ver, + MinCodexVersion: policy.MinCodexVersion, + } } if policy.MaxCodexVersion != "" && CompareVersions(ver, policy.MaxCodexVersion) > 0 { - return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionTooHigh} + return CodexClientRestrictionDetectionResult{ + Enabled: true, + Matched: false, + Reason: CodexClientRestrictionReasonVersionTooHigh, + DetectedVersion: ver, + MaxCodexVersion: policy.MaxCodexVersion, + } } } @@ -145,3 +170,22 @@ func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *A return CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: reason} } + +// CodexClientRestrictionMessage 把检测结果映射为面向客户端的 403 文案。 +// 仅版本越界(VersionTooLow/VersionTooHigh)给出带实际版本号与边界的差异化提示—— +// 这类请求其实已被识别为官方 Codex(命中官方 UA/originator),再回「只允许官方客户端」会误导; +// 其余拒绝原因统一沿用通用兜底句,不暴露门控细节。 +func CodexClientRestrictionMessage(r CodexClientRestrictionDetectionResult) string { + switch r.Reason { + case CodexClientRestrictionReasonVersionTooLow: + return fmt.Sprintf( + "Your Codex version (%s) is below the minimum required version (%s). Please update Codex.", + r.DetectedVersion, r.MinCodexVersion) + case CodexClientRestrictionReasonVersionTooHigh: + return fmt.Sprintf( + "Your Codex version (%s) exceeds the maximum allowed version (%s). Please downgrade Codex to %s or lower.", + r.DetectedVersion, r.MaxCodexVersion, r.MaxCodexVersion) + default: + return CodexOfficialClientsOnlyMessage + } +} diff --git a/backend/internal/service/openai_client_restriction_detector_test.go b/backend/internal/service/openai_client_restriction_detector_test.go index 291c79f6bf..6c79432ae2 100644 --- a/backend/internal/service/openai_client_restriction_detector_test.go +++ b/backend/internal/service/openai_client_restriction_detector_test.go @@ -284,6 +284,66 @@ func TestDetect_V3_AppServerAndSkipAndVersionScope(t *testing.T) { }) } +func TestDetect_VersionGateCarriesVersionFields(t *testing.T) { + gin.SetMode(gin.TestMode) + d := NewOpenAICodexClientRestrictionDetector(nil) + acc := func() *Account { + return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}} + } + + t.Run("版本太低:携带 DetectedVersion + MinCodexVersion", func(t *testing.T) { + c := newCodexDetectorTestContext("codex_cli_rs/0.39.0 (x)", "") + r := d.Detect(c, acc(), CodexRestrictionPolicy{MinCodexVersion: "0.42.0"}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonVersionTooLow, r.Reason) + require.Equal(t, "0.39.0", r.DetectedVersion) + require.Equal(t, "0.42.0", r.MinCodexVersion) + }) + + t.Run("版本太高:携带 DetectedVersion + MaxCodexVersion", func(t *testing.T) { + c := newCodexDetectorTestContext("codex_cli_rs/0.45.0 (x)", "") + r := d.Detect(c, acc(), CodexRestrictionPolicy{MaxCodexVersion: "0.42.0"}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonVersionTooHigh, r.Reason) + require.Equal(t, "0.45.0", r.DetectedVersion) + require.Equal(t, "0.42.0", r.MaxCodexVersion) + }) +} + +func TestCodexClientRestrictionMessage(t *testing.T) { + t.Run("版本太低:带实际版本与最低要求", func(t *testing.T) { + msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{ + Reason: CodexClientRestrictionReasonVersionTooLow, + DetectedVersion: "0.39.0", + MinCodexVersion: "0.42.0", + }) + require.Equal(t, "Your Codex version (0.39.0) is below the minimum required version (0.42.0). Please update Codex.", msg) + }) + + t.Run("版本太高:带实际版本与最高允许", func(t *testing.T) { + msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{ + Reason: CodexClientRestrictionReasonVersionTooHigh, + DetectedVersion: "0.45.0", + MaxCodexVersion: "0.42.0", + }) + require.Equal(t, "Your Codex version (0.45.0) exceeds the maximum allowed version (0.42.0). Please downgrade Codex to 0.42.0 or lower.", msg) + }) + + t.Run("无法识别版本:保持原通用句", func(t *testing.T) { + msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{ + Reason: CodexClientRestrictionReasonVersionUndetectable, + }) + require.Equal(t, "This account only allows Codex official clients", msg) + }) + + t.Run("未命中官方:保持原通用句", func(t *testing.T) { + msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{ + Reason: CodexClientRestrictionReasonNotMatchedUA, + }) + require.Equal(t, "This account only allows Codex official clients", msg) + }) +} + func TestDetect_EngineFingerprintSignals(t *testing.T) { gin.SetMode(gin.TestMode) det := NewOpenAICodexClientRestrictionDetector(&config.Config{}) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index f13c44f3a6..645b31992a 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -2617,7 +2617,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco c.JSON(http.StatusForbidden, gin.H{ "error": gin.H{ "type": "forbidden_error", - "message": "This account only allows Codex official clients", + "message": CodexClientRestrictionMessage(restrictionResult), }, }) return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed") diff --git a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go index 23a1750021..7eb133c125 100644 --- a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go +++ b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go @@ -59,6 +59,52 @@ func TestOpenAIGatewayService_GetCodexClientRestrictionDetector(t *testing.T) { }) } +func TestOpenAIGatewayService_Forward_VersionGateMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + + newCtx := func() (*httptest.ResponseRecorder, *gin.Context) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + return rec, c + } + account := func() *Account { + return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}} + } + body := []byte(`{"model":"gpt-5.1-codex"}`) + + t.Run("版本太低:返回带版本号的差异化文案", func(t *testing.T) { + rec, c := newCtx() + svc := &OpenAIGatewayService{codexDetector: &stubCodexRestrictionDetector{result: CodexClientRestrictionDetectionResult{ + Enabled: true, + Matched: false, + Reason: CodexClientRestrictionReasonVersionTooLow, + DetectedVersion: "0.39.0", + MinCodexVersion: "0.42.0", + }}} + + _, err := svc.Forward(context.Background(), c, account(), body) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "Your Codex version (0.39.0) is below the minimum required version (0.42.0)") + require.NotContains(t, rec.Body.String(), "This account only allows Codex official clients") + }) + + t.Run("未命中官方:仍返回通用兜底文案", func(t *testing.T) { + rec, c := newCtx() + svc := &OpenAIGatewayService{codexDetector: &stubCodexRestrictionDetector{result: CodexClientRestrictionDetectionResult{ + Enabled: true, + Matched: false, + Reason: CodexClientRestrictionReasonNotMatchedUA, + }}} + + _, err := svc.Forward(context.Background(), c, account(), body) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "This account only allows Codex official clients") + }) +} + func TestGetAPIKeyIDFromContext(t *testing.T) { gin.SetMode(gin.TestMode)