From 1f5ee8123fbc831726a6fe69d7b3a2e118062cce Mon Sep 17 00:00:00 2001 From: alfadb Date: Thu, 16 Jul 2026 15:45:22 +0800 Subject: [PATCH] fix(gateway): apply full Claude Code mimicry to Haiku --- backend/internal/pkg/claude/constants.go | 7 +- ...teway_anthropic_apikey_passthrough_test.go | 121 +++++++++++++++--- .../service/gateway_claude_oauth_body.go | 4 +- .../gateway_context_management_test.go | 98 +++++++++++--- backend/internal/service/gateway_forward.go | 16 +-- .../service/gateway_upstream_request.go | 18 +-- 6 files changed, 206 insertions(+), 58 deletions(-) diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index b159f8f5a5..be8e0d5528 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -48,7 +48,8 @@ const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + Beta // CountTokensBetaHeader count_tokens 请求使用的 anthropic-beta header const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting -// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta) +// HaikuBetaHeader Haiku 模型在 OAuth 真实客户端透传路径上的默认 anthropic-beta header。 +// OAuth mimic 路径统一使用 FullClaudeCodeMimicryBetas。 const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking // APIKeyBetaHeader API-key 账号建议使用的 anthropic-beta header(不包含 oauth) @@ -72,8 +73,8 @@ const CLICurrentVersion = "2.1.161" // 顺序与真实 CLI 抓包一致。 // // 使用建议: -// - OAuth 账号 + 非 haiku:追加这整份列表,再按需保留 client 带来的 beta。 -// - OAuth 账号 + haiku:Anthropic 对 haiku 不做 third-party 判定,使用 HaikuBetaHeader 即可。 +// - OAuth mimic:所有模型(包括 Haiku)都使用这整份列表。 +// - OAuth 真实客户端透传:保留客户端 beta;未提供时使用模型对应默认值。 // - API-key 账号:不要使用本函数,参见 APIKeyBetaHeader。 // - 不默认加入 redact-thinking,避免上游抹除 thinking 内容;客户端显式传入时由合并逻辑保留。 func FullClaudeCodeMimicryBetas() []string { diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 721212f6a5..b687696cfe 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -794,20 +794,34 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta") } -func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) { +func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { - name string - body string + name string + body string + wantModel string + wantOriginalSystem string + wantMetadataUserID string }{ { - name: "system array", - body: `{"model":"claude-3-5-sonnet-latest","system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + name: "sonnet system array", + body: `{"model":"claude-3-5-sonnet-latest","system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + wantModel: "claude-3-5-sonnet-latest", + wantOriginalSystem: "x-anthropic-billing-header keep", }, { - name: "system string", - body: `{"model":"claude-3-5-sonnet-latest","system":"x-anthropic-billing-header keep","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + name: "sonnet system string", + body: `{"model":"claude-3-5-sonnet-latest","system":"x-anthropic-billing-header keep","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + wantModel: "claude-3-5-sonnet-latest", + wantOriginalSystem: "x-anthropic-billing-header keep", + }, + { + name: "haiku full mimicry", + body: `{"model":"claude-haiku-4-5","metadata":{"user_id":"pi-session-metadata"},"system":[{"type":"text","text":"Pi project instructions","cache_control":{"type":"ephemeral"}}],"thinking":{"type":"enabled","budget_tokens":1024},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + wantModel: "claude-haiku-4-5-20251001", + wantOriginalSystem: "Pi project instructions", + wantMetadataUserID: "pi-session-metadata", }, } @@ -816,6 +830,8 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("User-Agent", "pi/0.51.0") + c.Request.Header.Set("Anthropic-Beta", "client-only-beta") parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(tt.body)), PlatformAnthropic) require.NoError(t, err) @@ -825,9 +841,9 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( StatusCode: http.StatusOK, Header: http.Header{ "Content-Type": []string{"application/json"}, - "x-request-id": []string{"rid-oauth-preserve"}, + "x-request-id": []string{"rid-oauth-mimic"}, }, - Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet-20241022","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)), + Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)), }, } @@ -846,7 +862,7 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( account := &Account{ ID: 301, - Name: "anthropic-oauth-preserve", + Name: "anthropic-oauth-mimic", Platform: PlatformAnthropic, Type: AccountTypeOAuth, Concurrency: 1, @@ -862,16 +878,27 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( require.NotNil(t, result) require.NotNil(t, upstream.lastReq) require.Equal(t, "Bearer oauth-token", getHeaderRaw(upstream.lastReq.Header, "authorization")) - require.Contains(t, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"), claude.BetaOAuth) + finalBeta := getHeaderRaw(upstream.lastReq.Header, "anthropic-beta") + for _, beta := range claude.FullClaudeCodeMimicryBetas() { + require.Truef(t, anthropicBetaTokensContains(finalBeta, beta), "missing mimic beta %s", beta) + } + require.False(t, anthropicBetaTokensContains(finalBeta, "client-only-beta")) + for key, value := range claude.DefaultHeaders { + require.Equal(t, value, getHeaderRaw(upstream.lastReq.Header, key), "mimic fingerprint header %s", key) + } + require.NotEmpty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id")) + require.Equal(t, tt.wantModel, gjson.GetBytes(upstream.lastBody, "model").String()) system := gjson.GetBytes(upstream.lastBody, "system") require.True(t, system.Exists()) require.True(t, system.IsArray(), "system should be an array") arr := system.Array() require.Len(t, arr, 3, "system array should have billing block + cc prompt block + expansion block") - require.Contains(t, arr[0].Get("text").String(), "x-anthropic-billing-header:") - require.Contains(t, arr[0].Get("text").String(), "cc_version=") + billingText := arr[0].Get("text").String() + require.Contains(t, billingText, "x-anthropic-billing-header:") + require.Contains(t, billingText, "cc_version="+claude.CLICurrentVersion+".") + require.Contains(t, billingText, "cc_entrypoint=cli;") require.Equal(t, claudeCodeSystemPrompt, arr[1].Get("text").String()) require.False(t, arr[1].Get("cache_control").Exists(), "身份前缀 block 不应带 cache_control") @@ -879,16 +906,80 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( require.Equal(t, claudeCodeSystemPromptExpansion, arr[2].Get("text").String()) require.Equal(t, "ephemeral", arr[2].Get("cache_control.type").String()) - // 原始 system prompt 应迁移至 messages 中 + // 原始 system prompt 应迁移至 messages 中。 messages := gjson.GetBytes(upstream.lastBody, "messages") require.True(t, messages.IsArray()) firstMsg := messages.Array()[0] require.Equal(t, "user", firstMsg.Get("role").String()) - require.Contains(t, firstMsg.Get("content.0.text").String(), "x-anthropic-billing-header keep") + require.Contains(t, firstMsg.Get("content.0.text").String(), tt.wantOriginalSystem) + + if tt.wantMetadataUserID != "" { + require.Equal(t, tt.wantMetadataUserID, gjson.GetBytes(upstream.lastBody, "metadata.user_id").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "context_management").Exists()) + } }) } } +func TestGatewayService_AnthropicOAuthRealClaudeCodeHaiku_PreservesClientHeadersAndBody(t *testing.T) { + gin.SetMode(gin.TestMode) + + metadataUserID := FormatMetadataUserID( + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "550e8400-e29b-41d4-a716-446655440000", + "123e4567-e89b-42d3-a456-426614174000", + claude.CLICurrentVersion, + ) + body := []byte(`{"model":"claude-haiku-4-5-20251001","metadata":{"user_id":` + strconvQuote(metadataUserID) + `},"system":[{"type":"text","text":"Client-owned Claude Code system","cache_control":{"type":"ephemeral"}}],"context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("User-Agent", "claude-cli/"+claude.CLICurrentVersion+" (external, cli)") + c.Request.Header.Set("X-Stainless-Package-Version", "real-client-package") + clientBeta := strings.Join([]string{ + claude.BetaClaudeCode, + claude.BetaOAuth, + claude.BetaInterleavedThinking, + claude.BetaContextManagement, + }, ",") + c.Request.Header.Set("Anthropic-Beta", clientBeta) + + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"msg_real_cc","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)), + }} + cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}} + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: &RateLimitService{}, + deferredService: &DeferredService{}, + } + account := &Account{ + ID: 302, Name: "anthropic-real-cc", Platform: PlatformAnthropic, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token"}, Status: StatusActive, Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, c.Request.Header.Get("User-Agent"), getHeaderRaw(upstream.lastReq.Header, "User-Agent")) + require.Equal(t, "real-client-package", getHeaderRaw(upstream.lastReq.Header, "X-Stainless-Package-Version")) + require.Equal(t, clientBeta, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta")) + require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id"), "真实 CC 不应被强制写入 mimic request id") + require.Equal(t, gjson.GetBytes(body, "system").Raw, gjson.GetBytes(upstream.lastBody, "system").Raw) + require.Equal(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(upstream.lastBody, "messages").Raw) + require.Equal(t, metadataUserID, gjson.GetBytes(upstream.lastBody, "metadata.user_id").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "context_management").Exists()) + require.NotContains(t, string(upstream.lastBody), "x-anthropic-billing-header:") +} + func TestGatewayService_AnthropicOAuth_SystemPromptInjectionCanBeDisabled(t *testing.T) { gin.SetMode(gin.TestMode) resetGatewayForwardingSettingsCacheForTest(t) diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 80604dd8ac..9fbbc674d8 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -373,7 +373,7 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account // - account:必须是 OAuth 账号,且调用方已判断不是 Claude Code 客户端。 // - body:已经 marshal 成 Anthropic /v1/messages 格式的请求体。 // - systemRaw:body 中原始 system 字段(用于判断是否需要 rewrite)。 -// - model:最终会发给上游的模型 ID(用于 haiku 旁路 + metadata 版本选择)。 +// - model:最终会发给上游的模型 ID(用于模型规范化 + metadata 版本选择)。 // // 返回:改写后的 body。即使中间任何一步失败,也会退化成原 body(不会 panic)。 func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( @@ -390,7 +390,7 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) systemRewritten := false - if systemPromptInjectionEnabled && !strings.Contains(strings.ToLower(model), "haiku") { + if systemPromptInjectionEnabled { body = rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), systemPrompt, systemPromptBlocks) systemRewritten = true } diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index df23974ee0..b0f2e5b820 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -146,14 +146,15 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement require.True(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode)) } -func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_ExcludesContextManagement(t *testing.T) { +func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_IncludesFullClaudeCodeBetas(t *testing.T) { s := newTestGatewayServiceForBeta(false) final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil) require.True(t, ok) - require.False(t, anthropicBetaTokensContains(final, claude.BetaContextManagement), - "OAuth mimic haiku 仅注入 oauth + interleaved-thinking,不含 context-management") - require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth)) - require.True(t, anthropicBetaTokensContains(final, claude.BetaInterleavedThinking)) + require.Equal(t, strings.Join(claude.FullClaudeCodeMimicryBetas(), ","), final) + for _, beta := range claude.FullClaudeCodeMimicryBetas() { + require.Truef(t, anthropicBetaTokensContains(final, beta), + "OAuth mimic Haiku 必须包含完整 Claude Code beta 集合,缺少 %s", beta) + } } func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBeta(t *testing.T) { @@ -206,17 +207,27 @@ func TestComputeFinalAnthropicBeta_APIKey_NoClientBetaInjectOff_ShouldNotSet(t * require.Equal(t, "", final) } +func TestComputeFinalAnthropicBeta_APIKeyHaiku_StillUsesAPIKeyBetas(t *testing.T) { + s := newTestGatewayServiceForBeta(true) + body := []byte(`{"model":"claude-haiku-4-5","thinking":{"type":"enabled"},"messages":[]}`) + final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-haiku-4-5", http.Header{}, body, nil) + require.True(t, ok) + require.Equal(t, claude.APIKeyHaikuBetaHeader, final) + require.False(t, anthropicBetaTokensContains(final, claude.BetaOAuth)) + require.False(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode)) +} + // ============================================================================ // computeFinalCountTokensAnthropicBeta // ============================================================================ func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_AlwaysIncludesContextManagement(t *testing.T) { - // count_tokens 路径下 mimic 不按 haiku 排除:始终注入完整 mimicry beta + // count_tokens mimic 继续注入完整 mimicry beta,并额外携带 token-counting beta。 s := newTestGatewayServiceForBeta(false) final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil) require.True(t, ok) require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement), - "count_tokens + mimic 即使 haiku 也注入 context-management beta(与 messages 不同)") + "count_tokens + mimic Haiku 必须保留 context-management beta") require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting), "count_tokens 路径必须含 token-counting beta") } @@ -300,8 +311,8 @@ func TestNormalizeClaudeOAuthRequestBody_InjectsContextManagement_ThinkingAdapti } func TestNormalizeClaudeOAuthRequestBody_HaikuStillInjects_StripDeferredToSanitize(t *testing.T) { - // haiku + thinking=enabled:normalize 阶段仍按 CLI mimicry 行为补齐字段; - // strip 由 buildUpstreamRequest 层的 sanitize 兜底(如果 final beta 不含 token)。 + // Haiku + thinking=enabled:normalize 阶段仍按 CLI mimicry 行为补齐字段; + // 最终是否保留仍由 beta 能力对称的 sanitize 统一决定。 body := []byte(`{"model":"claude-haiku-4-5","thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`) out, _ := normalizeClaudeOAuthRequestBody(body, "claude-haiku-4-5", claudeOAuthNormalizeOptions{}) require.True(t, gjson.GetBytes(out, "context_management").Exists(), @@ -322,6 +333,30 @@ func TestNormalizeClaudeOAuthRequestBody_NoThinking_NoInject(t *testing.T) { require.False(t, gjson.GetBytes(out, "context_management").Exists()) } +func TestNormalizeClaudeOAuthRequestBody_HaikuShortModelStillNormalizesToDatedID(t *testing.T) { + body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`) + out, modelID := normalizeClaudeOAuthRequestBody(body, "claude-haiku-4-5", claudeOAuthNormalizeOptions{}) + require.Equal(t, "claude-haiku-4-5-20251001", modelID) + require.Equal(t, "claude-haiku-4-5-20251001", gjson.GetBytes(out, "model").String()) +} + +func TestApplyClaudeCodeOAuthMimicryToBody_HaikuRewritesSystem(t *testing.T) { + account := &Account{ID: 405, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + body := []byte(`{"model":"claude-haiku-4-5","system":"Pi project instructions","messages":[{"role":"user","content":"hello"}]}`) + svc := &GatewayService{cfg: &config.Config{}} + + out := svc.applyClaudeCodeOAuthMimicryToBody( + context.Background(), nil, account, body, "Pi project instructions", "claude-haiku-4-5", + ) + + system := gjson.GetBytes(out, "system").Array() + require.Len(t, system, 3) + require.Contains(t, system[0].Get("text").String(), "x-anthropic-billing-header:") + require.Equal(t, claudeCodeSystemPrompt, system[1].Get("text").String()) + require.Contains(t, gjson.GetBytes(out, "messages.0.content.0.text").String(), "Pi project instructions") + require.Equal(t, "claude-haiku-4-5-20251001", gjson.GetBytes(out, "model").String()) +} + // ============================================================================ // passthrough 集成测试:buildUpstreamRequest- // AnthropicAPIKeyPassthrough 与 buildCountTokensRequestAnthropicAPIKeyPassthrough @@ -411,7 +446,7 @@ func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_StripsContextManageme // 这个测试能挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。 // ============================================================================ -func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsContextManagementEndToEnd(t *testing.T) { +func TestBuildUpstreamRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -422,8 +457,7 @@ func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsContextManagementEndToEnd(t Status: StatusActive, Schedulable: true, } - // haiku + mimic CC → final beta = HaikuBetaHeader(不含 context-management)→ - // body 必须 strip。 + // Haiku + mimic CC 使用完整 beta,其中包含 context-management;body 必须对称保留。 body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`) svc := &GatewayService{cfg: &config.Config{}} req, _, err := svc.buildUpstreamRequest( @@ -435,10 +469,38 @@ func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsContextManagementEndToEnd(t outBody := readUpstreamBodyForTest(t, req) outBeta := getHeaderRaw(req.Header, "anthropic-beta") - require.False(t, gjson.GetBytes(outBody, "context_management").Exists(), - "OAuth mimic + haiku 端到端:outgoing body 不应含 context_management") - require.False(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement), - "对称约束:outgoing anthropic-beta header 也不带 context-management beta") + require.True(t, gjson.GetBytes(outBody, "context_management").Exists(), + "OAuth mimic + Haiku 端到端:outgoing body 必须保留 context_management") + require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement), + "对称约束:outgoing anthropic-beta header 必须包含 context-management beta") + require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaClaudeCode), + "Haiku mimic 必须携带 claude-code beta") +} + +func TestBuildUpstreamRequest_APIKeyHaiku_RemainsUnmimicked(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + account := &Account{ + ID: 404, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-ant-xxx"}, + Status: StatusActive, Schedulable: true, + } + body := []byte(`{"model":"claude-haiku-4-5","system":"API-key client system","thinking":{"type":"enabled"},"messages":[]}`) + svc := newTestGatewayServiceForBeta(true) + req, _, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, + "sk-ant-xxx", "apikey", "claude-haiku-4-5", false, false, + ) + require.NoError(t, err) + + outBody := readUpstreamBodyForTest(t, req) + require.Equal(t, "API-key client system", gjson.GetBytes(outBody, "system").String()) + require.Equal(t, claude.APIKeyHaikuBetaHeader, getHeaderRaw(req.Header, "anthropic-beta")) + require.False(t, anthropicBetaTokensContains(getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth)) + require.NotContains(t, string(outBody), "x-anthropic-billing-header:") } func TestBuildUpstreamRequest_OAuthMimicNonHaiku_PreservesContextManagementEndToEnd(t *testing.T) { @@ -504,8 +566,8 @@ func TestBuildUpstreamRequest_OAuthTransparentHaikuWithRealCCBeta_PreservesField // count_tokens 主路径 E2E 集成测试 func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) { - // count_tokens 路径下 mimic 不按 haiku 排除,始终注入 BetaContextManagement - // → sanitize 看到最终 beta header 含 context-management beta → 字段保留。 + // count_tokens 继续注入 BetaContextManagement 和 BetaTokenCounting; + // sanitize 看到最终 beta header 含 context-management beta 后保留字段。 gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 1418989780..2e6093d18e 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -179,19 +179,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。 // Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。 systemRewritten := false - if !strings.Contains(strings.ToLower(reqModel), "haiku") { - systemRaw, _ := parsed.SystemValue() - systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) - if systemPromptInjectionEnabled { - if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil { - return nil, err - } - systemRewritten = true + systemRaw, _ := parsed.SystemValue() + systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) + if systemPromptInjectionEnabled { + if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil { + return nil, err } + systemRewritten = true } // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为); - // 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。 + // 未重写时(注入开关关闭)剥离客户端 cache_control,与原有行为一致。 // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} if s.identityService != nil && c != nil { diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index ac13f0be59..c5d962f20b 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -373,8 +373,8 @@ func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) return claude.BetaOAuth + "," + clientBetaHeader } - // 客户端没传,根据模型生成 - // haiku 模型不需要 claude-code beta + // OAuth 真实客户端透传且客户端没传 beta 时,根据模型生成默认值。 + // Haiku 的透传默认值不补 claude-code beta;mimic 路径不会调用本分支。 if strings.Contains(strings.ToLower(modelID), "haiku") { return claude.HaikuBetaHeader } @@ -496,13 +496,9 @@ func (s *GatewayService) computeFinalAnthropicBeta( if tokenType == "oauth" { if mimicClaudeCode { - // mimic 路径:原代码跳过白名单透传,incomingBeta 总是空字符串。 - // 这里传空 string 以严格对齐原行为。 - requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} - if !strings.Contains(strings.ToLower(modelID), "haiku") { - requiredBetas = claude.FullClaudeCodeMimicryBetas() - } - return mergeAnthropicBetaDropping(requiredBetas, "", effectiveDropSet), true + // mimic 路径跳过白名单透传,incomingBeta 始终为空;所有模型都必须 + // 携带完整 Claude Code beta 集合,避免 Haiku 被识别为第三方客户端。 + return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), "", effectiveDropSet), true } // 真 Claude Code 客户端透传路径 return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true @@ -526,8 +522,8 @@ func (s *GatewayService) computeFinalAnthropicBeta( // 计算纯函数。语义与 computeFinalAnthropicBeta 对齐,但备份了 count_tokens 独有的 // 两条特殊规则: // -// - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting -// (与 messages 不同的是:不按 haiku 排除;count_tokens 始终携带 token-counting beta) +// - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting; +// count_tokens 另外保留客户端 beta,而 messages mimic 会忽略客户端 beta。 // - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader // - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含) //