diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index b56861ebb6..4da9668dd8 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -2103,6 +2103,9 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection( req.Header.Set("Accept", "text/event-stream") req.Header.Set("Authorization", "Bearer "+authToken) + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA,与真实转发路径一致。 + applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header) + // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index a68a691d27..4fe14c380b 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -84,6 +84,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co // Ollama Cloud Anthropic 兼容端点按 adaptive 实际选用的 Anthropic // base_url 强制 Bearer,其余保持 extra/default 行为。 setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic)) + applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) @@ -178,6 +179,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co req.Header.Set("Accept", "text/event-stream") req.Header.Set("Authorization", "Bearer "+authToken) applyOpenAICodexProbeHeaders(req.Header) + applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) @@ -267,6 +269,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持 // extra/default 行为。 setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL()) + applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 103423e8aa..0fe504a6f8 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -225,6 +225,10 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( } applyGrokCacheHeaders(upstreamReq.Header, grokCacheIdentity) } + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库 + // UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。 + applyOpenCodeUpstreamUserAgent(account, targetURL, upstreamReq.Header) + // 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用, // 使配置值获得除共享传输层强制头之外的最高优先级。 account.ApplyHeaderOverrides(upstreamReq.Header) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 7f899d2c34..036754037f 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1510,6 +1510,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("content-type", "application/json") } + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库 + // UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。 + applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header) + // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body, openCodeSessionHintBody(promptCacheKey)) diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 263eceba04..1634a556fc 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -208,6 +208,10 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") } + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库 + // UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。 + applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header) + // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) account.ApplyHeaderOverrides(req.Header) payloads := append([][]byte{body}, sessionBodies...) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 4ff19f6781..701a431048 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -719,6 +719,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("content-type", "application/json") } + // 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库 + // UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。 + applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header) + // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body) diff --git a/backend/internal/service/openai_opencode_session.go b/backend/internal/service/openai_opencode_session.go index f5aaca641f..3e3f5faea3 100644 --- a/backend/internal/service/openai_opencode_session.go +++ b/backend/internal/service/openai_opencode_session.go @@ -14,6 +14,14 @@ import ( const ( openCodeSessionHeader = "X-OpenCode-Session" openCodeInboundBodyContextKey = "opencode_inbound_body" + + // openCodeUpstreamUserAgent 是发往官方 OpenCode 上游的规范 User-Agent。 + // opencode.ai 前置 Cloudflare 按客户端 UA 签名做 bot 拦截:Python-urllib 等 + // 编程库 UA 会被 CF error code 1010 拒绝并返回 403。网关透传白名单放行 + // user-agent,客户端自报身份原样到达上游会命中 WAF;该 403 再被计入账号 + // 凭证类 strike,健康账号因此被自动禁用。取值沿用真实 opencode 客户端的 + // UA 格式(opencode/)。 + openCodeUpstreamUserAgent = "opencode/1.0.0" ) // rememberOpenCodeInboundBody keeps the client request body so protocol @@ -97,6 +105,46 @@ func isOfficialOpenCodeHost(targetURL string) bool { return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "opencode.ai") } +func isOfficialCommandCodeHost(targetURL string) bool { + parsed, err := url.Parse(targetURL) + if err != nil { + return false + } + return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "api.commandcode.ai") +} + +// applyOpenCodeUpstreamUserAgent 将发往官方 OpenCode / Command Code 上游的出站 +// User-Agent 收敛为规范客户端身份,覆盖客户端透传值与平台默认 UA。判定规则与 +// x-opencode-session 同源:opencode 平台账号,或(任意平台账号)目标为官方主机。 +// 必须先于 Account.ApplyHeaderOverrides 调用——账号 header_overrides 中显式 +// 配置的 user-agent 仍拥有最终决定权。 +func applyOpenCodeUpstreamUserAgent(account *Account, targetURL string, headers http.Header) { + if headers == nil { + return + } + + userAgent := "" + switch { + case isOfficialCommandCodeHost(targetURL): + userAgent = CodexCanonicalUserAgent() + case account != nil && account.IsOpenCodeGo(), isOfficialOpenCodeHost(targetURL): + userAgent = openCodeUpstreamUserAgent + default: + return + } + if userAgent == "" { + return + } + + // 先删任意大小写变体再写入:透传链路与覆写直写 map 可能残留非 canonical 键。 + for key := range headers { + if strings.EqualFold(key, "User-Agent") { + delete(headers, key) + } + } + headers.Set("User-Agent", userAgent) +} + func resolveOpenCodeSessionID(c *gin.Context, headers http.Header, generate bool, bodies ...[]byte) string { if c != nil && c.Request != nil { if sessionID := sanitizeSessionID(c.GetHeader(openCodeSessionHeader)); sessionID != "" { diff --git a/backend/internal/service/openai_opencode_session_test.go b/backend/internal/service/openai_opencode_session_test.go index b4e455df7c..53341f8e26 100644 --- a/backend/internal/service/openai_opencode_session_test.go +++ b/backend/internal/service/openai_opencode_session_test.go @@ -307,6 +307,188 @@ func (u *openCodeSessionHTTPUpstream) DoWithTLS(req *http.Request, proxyURL stri return u.Do(req, proxyURL, accountID, concurrency) } +func TestApplyOpenCodeUpstreamUserAgent(t *testing.T) { + tests := []struct { + name string + account *Account + targetURL string + seed string + want string + }{ + { + name: "opencode go account canonicalizes passthrough UA", + account: &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + targetURL: "https://opencode.ai/zen/go/v1/chat/completions", + seed: "Python-urllib/3.13", + want: openCodeUpstreamUserAgent, + }, + { + name: "opencode go account canonicalizes on custom relay too", + account: &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + targetURL: "https://relay.example.com/v1/chat/completions", + seed: "Python-urllib/3.13", + want: openCodeUpstreamUserAgent, + }, + { + name: "non-opencode account targeting official host", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://opencode.ai/zen/go/v1/responses", + seed: "Python-urllib/3.13", + want: openCodeUpstreamUserAgent, + }, + { + name: "official command code host uses canonical Codex UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://api.commandcode.ai/provider/v1/responses", + seed: "Python-urllib/3.13", + want: CodexCanonicalUserAgent(), + }, + { + name: "command code lookalike host keeps client UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://api.commandcode.ai.evil.example/provider/v1/responses", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "command code subdomain keeps client UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://proxy.api.commandcode.ai/provider/v1/responses", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "insecure command code host keeps client UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "http://api.commandcode.ai/provider/v1/responses", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "non-official account and host keeps client UA", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://api.openai.com/v1/chat/completions", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "lookalike host does not count as official", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + targetURL: "https://opencode.ai.evil.example/v1/chat/completions", + seed: "Python-urllib/3.13", + want: "Python-urllib/3.13", + }, + { + name: "missing UA is filled for opencode account", + account: &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + targetURL: "https://opencode.ai/zen/go/v1/messages", + want: openCodeUpstreamUserAgent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := make(http.Header) + if tt.seed != "" { + headers.Set("User-Agent", tt.seed) + } + applyOpenCodeUpstreamUserAgent(tt.account, tt.targetURL, headers) + require.Equal(t, tt.want, headers.Get("User-Agent")) + }) + } +} + +func TestApplyOpenCodeUpstreamUserAgentDropsNonCanonicalKeyVariants(t *testing.T) { + headers := http.Header{"user-agent": []string{"Python-urllib/3.13"}} + applyOpenCodeUpstreamUserAgent(&Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + "https://opencode.ai/zen/go/v1/chat/completions", headers) + + count := 0 + for key, values := range headers { + if strings.EqualFold(key, "User-Agent") { + count += len(values) + require.Equal(t, []string{openCodeUpstreamUserAgent}, values) + } + } + require.Equal(t, 1, count) +} + +func TestApplyOpenCodeUpstreamUserAgentNilSafe(t *testing.T) { + applyOpenCodeUpstreamUserAgent(&Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}, + "https://opencode.ai/zen/go/v1/chat/completions", nil) +} + +func TestOpenCodeUpstreamUserAgentCanonicalizedAfterPassthrough(t *testing.T) { + // 回归:客户端透传的编程库 UA(Python-urllib)会被 opencode.ai 前置 + // Cloudflare 以 error code 1010 拦截并计入账号 403 strike,导致健康账号 + // 被自动禁用。出站 UA 必须收敛为规范 opencode 客户端身份。 + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := &Account{ + ID: 1, + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://opencode.ai/zen/go/v1", + }, + } + c := newOpenCodeSessionTestContext(t, "") + c.Request.Header.Set("User-Agent", "Python-urllib/3.13") + + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"glm-5.3","input":"hello"}`), "token", false, "", false, + ) + require.NoError(t, err) + require.Equal(t, openCodeUpstreamUserAgent, req.Header.Get("User-Agent")) +} + +func TestCommandCodeUpstreamUserAgentCanonicalizedAfterPassthrough(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := &Account{ + ID: 2, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://api.commandcode.ai/provider/v1", + }, + } + c := newOpenCodeSessionTestContext(t, "") + c.Request.Header.Set("User-Agent", "Python-urllib/3.13") + + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"gpt-5","input":"hello"}`), "token", false, "", false, + ) + require.NoError(t, err) + require.Equal(t, CodexCanonicalUserAgent(), req.Header.Get("User-Agent")) +} + +func TestOpenCodeUpstreamUserAgentYieldsToExplicitAccountOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := &Account{ + ID: 1, + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://opencode.ai/zen/go/v1", + credKeyHeaderOverrideEnabled: true, + credKeyHeaderOverrides: map[string]any{"user-agent": "custom-relay-agent/2.0"}, + }, + } + c := newOpenCodeSessionTestContext(t, "") + c.Request.Header.Set("User-Agent", "Python-urllib/3.13") + + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"glm-5.3","input":"hello"}`), "token", false, "", false, + ) + require.NoError(t, err) + require.Equal(t, "custom-relay-agent/2.0", req.Header.Get("User-Agent")) +} + func TestOpenCodeSessionForwardedByRawChatCompletionsAfterAccountOverride(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &openCodeSessionHTTPUpstream{} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 6fc119f7ba..5c80de5a6f 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1039,6 +1039,15 @@ func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account ) return false } + if isCloudflareBotBlockResponse(responseBody) { + slog.Warn( + "openai_403_cloudflare_bot_block_skips_account_penalty", + "account_id", account.ID, + "platform", account.Platform, + "upstream_message", upstreamMsg, + ) + return false + } msg := buildForbiddenErrorMessage( "Access forbidden (403):", @@ -1084,6 +1093,14 @@ func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account return true } +// isCloudflareBotBlockResponse reports Cloudflare's WAF bot-signature response +// (error code 1010). The upstream never reached the account API, so this is a +// request/edge-level failure and must not consume the account 403 strike budget. +func isCloudflareBotBlockResponse(body []byte) bool { + normalized := strings.ToLower(strings.TrimSpace(string(body))) + return strings.Contains(normalized, "error code: 1010") +} + // handleAntigravity403 处理 Antigravity 平台的 403 错误 // validation(需要验证)→ 永久 SetError(需人工去 Google 验证后恢复) // violation(违规封号)→ 永久 SetError(需人工处理) diff --git a/backend/internal/service/ratelimit_service_403_html_test.go b/backend/internal/service/ratelimit_service_403_html_test.go index 5c8f782598..8768b41fea 100644 --- a/backend/internal/service/ratelimit_service_403_html_test.go +++ b/backend/internal/service/ratelimit_service_403_html_test.go @@ -68,6 +68,8 @@ func (h *openAI403TestHarness) requireNoAccountPenalty(t *testing.T) { const openAI403HTMLBody = "\n403 Forbidden" + "

403 Forbidden

" +const openAI403Cloudflare1010Body = "error code: 1010\n" + func TestHandleUpstreamError_OpenAIHTML403DoesNotPenalizeAccount(t *testing.T) { cases := []struct { name string @@ -102,6 +104,32 @@ func TestHandleUpstreamError_OpenAIHTML403RepeatedNeverEscalates(t *testing.T) { h.requireNoAccountPenalty(t) } +func TestHandleUpstreamError_OpenAICloudflare1010DoesNotPenalizeAccount(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformOpenCodeGo} { + t.Run(platform, func(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + counter := &countingOpenAI403CounterCache{ + openAI403CounterCacheStub: openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}}, + } + blocker := &runtimeBlockRecorder{} + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc.SetOpenAI403CounterCache(counter) + svc.SetAccountRuntimeBlocker(blocker) + account := &Account{ID: 601, Platform: platform, Type: AccountTypeAPIKey} + + shouldDisable := svc.HandleUpstreamError( + context.Background(), account, http.StatusForbidden, http.Header{}, []byte(openAI403Cloudflare1010Body), + ) + + require.False(t, shouldDisable, "Cloudflare 1010 不得判定账号应下线") + require.Equal(t, 0, counter.increments, "Cloudflare 1010 不得递增账号 403 计数") + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 0, repo.tempCalls) + require.Empty(t, blocker.accounts) + }) + } +} + // 对照不变式:真正的结构化 JSON 403 是账号级证据,处罚链路必须原样保留。 // 缺了这组断言,上面的跳过逻辑一旦写宽就会把真实的封号 403 也放过去。 func TestHandleUpstreamError_OpenAIStructured403StillPenalizes(t *testing.T) {