Skip to content

Commit 4b32114

Browse files
fix(codex-detect): codex 版本门拒绝返回带版本号的差异化文案
codex_cli_only 的 403 出口此前对所有拒绝原因硬编码同一句 "This account only allows Codex official clients",但能走到版本门 (判定链第 5 步)的请求其实已命中官方 UA/originator,仅版本不符, 再回「只允许官方客户端」会误导。 - CodexClientRestrictionDetectionResult 增 DetectedVersion/MinCodexVersion/ MaxCodexVersion,仅在 VersionTooLow/VersionTooHigh 分支填充 - 新增 CodexClientRestrictionMessage:too_low/too_high 给出带实际版本号与 边界的提示;undetectable/未命中/黑名单/缺指纹 仍用通用兜底句(不泄露门控细节) - Forward 唯一 403 出口改用该映射函数 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6cea1c3 commit 4b32114

4 files changed

Lines changed: 153 additions & 3 deletions

File tree

backend/internal/service/openai_client_restriction_detector.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
package service
22

33
import (
4+
"fmt"
45
"net/http"
56

67
"github.com/Wei-Shaw/sub2api/internal/config"
78
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
89
"github.com/gin-gonic/gin"
910
)
1011

12+
// CodexOfficialClientsOnlyMessage 是 codex_cli_only 拒绝时面向客户端的通用兜底文案。
13+
// 仅当拒绝原因不是「可解析版本但越界」(VersionTooLow/VersionTooHigh)时使用:
14+
// 未命中官方/黑名单/缺指纹/版本无法识别都沿用这句(避免向伪装客户端泄露门控细节)。
15+
const CodexOfficialClientsOnlyMessage = "This account only allows Codex official clients"
16+
1117
const (
1218
// CodexClientRestrictionReasonDisabled 表示账号未开启 codex_cli_only。
1319
CodexClientRestrictionReasonDisabled = "codex_cli_only_disabled"
@@ -51,6 +57,13 @@ type CodexClientRestrictionDetectionResult struct {
5157
Enabled bool
5258
Matched bool
5359
Reason string
60+
// DetectedVersion 是从官方 UA 解析出的 Codex 引擎版本;仅在版本门拒绝
61+
// (VersionTooLow / VersionTooHigh) 时填充,供面向客户端的差异化文案使用。
62+
DetectedVersion string
63+
// MinCodexVersion 是触发 VersionTooLow 时的最低要求版本(来自策略快照)。
64+
MinCodexVersion string
65+
// MaxCodexVersion 是触发 VersionTooHigh 时的最高允许版本(来自策略快照)。
66+
MaxCodexVersion string
5467
}
5568

5669
// CodexClientRestrictionDetector 定义 codex_cli_only 统一检测入口。
@@ -127,10 +140,22 @@ func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *A
127140
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionUndetectable}
128141
}
129142
if policy.MinCodexVersion != "" && CompareVersions(ver, policy.MinCodexVersion) < 0 {
130-
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionTooLow}
143+
return CodexClientRestrictionDetectionResult{
144+
Enabled: true,
145+
Matched: false,
146+
Reason: CodexClientRestrictionReasonVersionTooLow,
147+
DetectedVersion: ver,
148+
MinCodexVersion: policy.MinCodexVersion,
149+
}
131150
}
132151
if policy.MaxCodexVersion != "" && CompareVersions(ver, policy.MaxCodexVersion) > 0 {
133-
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionTooHigh}
152+
return CodexClientRestrictionDetectionResult{
153+
Enabled: true,
154+
Matched: false,
155+
Reason: CodexClientRestrictionReasonVersionTooHigh,
156+
DetectedVersion: ver,
157+
MaxCodexVersion: policy.MaxCodexVersion,
158+
}
134159
}
135160
}
136161

@@ -145,3 +170,22 @@ func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *A
145170

146171
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: reason}
147172
}
173+
174+
// CodexClientRestrictionMessage 把检测结果映射为面向客户端的 403 文案。
175+
// 仅版本越界(VersionTooLow/VersionTooHigh)给出带实际版本号与边界的差异化提示——
176+
// 这类请求其实已被识别为官方 Codex(命中官方 UA/originator),再回「只允许官方客户端」会误导;
177+
// 其余拒绝原因统一沿用通用兜底句,不暴露门控细节。
178+
func CodexClientRestrictionMessage(r CodexClientRestrictionDetectionResult) string {
179+
switch r.Reason {
180+
case CodexClientRestrictionReasonVersionTooLow:
181+
return fmt.Sprintf(
182+
"Your Codex version (%s) is below the minimum required version (%s). Please update Codex.",
183+
r.DetectedVersion, r.MinCodexVersion)
184+
case CodexClientRestrictionReasonVersionTooHigh:
185+
return fmt.Sprintf(
186+
"Your Codex version (%s) exceeds the maximum allowed version (%s). Please downgrade Codex to %s or lower.",
187+
r.DetectedVersion, r.MaxCodexVersion, r.MaxCodexVersion)
188+
default:
189+
return CodexOfficialClientsOnlyMessage
190+
}
191+
}

backend/internal/service/openai_client_restriction_detector_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,66 @@ func TestDetect_V3_AppServerAndSkipAndVersionScope(t *testing.T) {
284284
})
285285
}
286286

287+
func TestDetect_VersionGateCarriesVersionFields(t *testing.T) {
288+
gin.SetMode(gin.TestMode)
289+
d := NewOpenAICodexClientRestrictionDetector(nil)
290+
acc := func() *Account {
291+
return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}}
292+
}
293+
294+
t.Run("版本太低:携带 DetectedVersion + MinCodexVersion", func(t *testing.T) {
295+
c := newCodexDetectorTestContext("codex_cli_rs/0.39.0 (x)", "")
296+
r := d.Detect(c, acc(), CodexRestrictionPolicy{MinCodexVersion: "0.42.0"}, nil)
297+
require.False(t, r.Matched)
298+
require.Equal(t, CodexClientRestrictionReasonVersionTooLow, r.Reason)
299+
require.Equal(t, "0.39.0", r.DetectedVersion)
300+
require.Equal(t, "0.42.0", r.MinCodexVersion)
301+
})
302+
303+
t.Run("版本太高:携带 DetectedVersion + MaxCodexVersion", func(t *testing.T) {
304+
c := newCodexDetectorTestContext("codex_cli_rs/0.45.0 (x)", "")
305+
r := d.Detect(c, acc(), CodexRestrictionPolicy{MaxCodexVersion: "0.42.0"}, nil)
306+
require.False(t, r.Matched)
307+
require.Equal(t, CodexClientRestrictionReasonVersionTooHigh, r.Reason)
308+
require.Equal(t, "0.45.0", r.DetectedVersion)
309+
require.Equal(t, "0.42.0", r.MaxCodexVersion)
310+
})
311+
}
312+
313+
func TestCodexClientRestrictionMessage(t *testing.T) {
314+
t.Run("版本太低:带实际版本与最低要求", func(t *testing.T) {
315+
msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{
316+
Reason: CodexClientRestrictionReasonVersionTooLow,
317+
DetectedVersion: "0.39.0",
318+
MinCodexVersion: "0.42.0",
319+
})
320+
require.Equal(t, "Your Codex version (0.39.0) is below the minimum required version (0.42.0). Please update Codex.", msg)
321+
})
322+
323+
t.Run("版本太高:带实际版本与最高允许", func(t *testing.T) {
324+
msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{
325+
Reason: CodexClientRestrictionReasonVersionTooHigh,
326+
DetectedVersion: "0.45.0",
327+
MaxCodexVersion: "0.42.0",
328+
})
329+
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)
330+
})
331+
332+
t.Run("无法识别版本:保持原通用句", func(t *testing.T) {
333+
msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{
334+
Reason: CodexClientRestrictionReasonVersionUndetectable,
335+
})
336+
require.Equal(t, "This account only allows Codex official clients", msg)
337+
})
338+
339+
t.Run("未命中官方:保持原通用句", func(t *testing.T) {
340+
msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{
341+
Reason: CodexClientRestrictionReasonNotMatchedUA,
342+
})
343+
require.Equal(t, "This account only allows Codex official clients", msg)
344+
})
345+
}
346+
287347
func TestDetect_EngineFingerprintSignals(t *testing.T) {
288348
gin.SetMode(gin.TestMode)
289349
det := NewOpenAICodexClientRestrictionDetector(&config.Config{})

backend/internal/service/openai_gateway_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2617,7 +2617,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
26172617
c.JSON(http.StatusForbidden, gin.H{
26182618
"error": gin.H{
26192619
"type": "forbidden_error",
2620-
"message": "This account only allows Codex official clients",
2620+
"message": CodexClientRestrictionMessage(restrictionResult),
26212621
},
26222622
})
26232623
return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed")

backend/internal/service/openai_gateway_service_codex_cli_only_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,52 @@ func TestOpenAIGatewayService_GetCodexClientRestrictionDetector(t *testing.T) {
5959
})
6060
}
6161

62+
func TestOpenAIGatewayService_Forward_VersionGateMessage(t *testing.T) {
63+
gin.SetMode(gin.TestMode)
64+
65+
newCtx := func() (*httptest.ResponseRecorder, *gin.Context) {
66+
rec := httptest.NewRecorder()
67+
c, _ := gin.CreateTestContext(rec)
68+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
69+
return rec, c
70+
}
71+
account := func() *Account {
72+
return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}}
73+
}
74+
body := []byte(`{"model":"gpt-5.1-codex"}`)
75+
76+
t.Run("版本太低:返回带版本号的差异化文案", func(t *testing.T) {
77+
rec, c := newCtx()
78+
svc := &OpenAIGatewayService{codexDetector: &stubCodexRestrictionDetector{result: CodexClientRestrictionDetectionResult{
79+
Enabled: true,
80+
Matched: false,
81+
Reason: CodexClientRestrictionReasonVersionTooLow,
82+
DetectedVersion: "0.39.0",
83+
MinCodexVersion: "0.42.0",
84+
}}}
85+
86+
_, err := svc.Forward(context.Background(), c, account(), body)
87+
require.Error(t, err)
88+
require.Equal(t, http.StatusForbidden, rec.Code)
89+
require.Contains(t, rec.Body.String(), "Your Codex version (0.39.0) is below the minimum required version (0.42.0)")
90+
require.NotContains(t, rec.Body.String(), "This account only allows Codex official clients")
91+
})
92+
93+
t.Run("未命中官方:仍返回通用兜底文案", func(t *testing.T) {
94+
rec, c := newCtx()
95+
svc := &OpenAIGatewayService{codexDetector: &stubCodexRestrictionDetector{result: CodexClientRestrictionDetectionResult{
96+
Enabled: true,
97+
Matched: false,
98+
Reason: CodexClientRestrictionReasonNotMatchedUA,
99+
}}}
100+
101+
_, err := svc.Forward(context.Background(), c, account(), body)
102+
require.Error(t, err)
103+
require.Equal(t, http.StatusForbidden, rec.Code)
104+
require.Contains(t, rec.Body.String(), "This account only allows Codex official clients")
105+
})
106+
}
107+
62108
func TestGetAPIKeyIDFromContext(t *testing.T) {
63109
gin.SetMode(gin.TestMode)
64110

0 commit comments

Comments
 (0)