Merge pull request #3760 from DaydreamCoding/fix/codex-detect-version-gate-message

fix(codex-detect): codex 版本门拒绝返回带版本号的差异化文案
This commit is contained in:
Wesley Liddick
2026-07-07 08:28:51 +08:00
committed by GitHub
4 changed files with 153 additions and 3 deletions
@@ -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
}
}
@@ -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{})
@@ -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")
@@ -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)