Merge pull request #4428 from alfadb/fix/anthropic-haiku-full-mimicry

fix(gateway): 修复 Haiku OAuth 请求被归入额外用量
This commit is contained in:
Wesley Liddick
2026-07-16 16:55:11 +08:00
committed by GitHub
6 changed files with 206 additions and 58 deletions
+4 -3
View File
@@ -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 {
@@ -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)
@@ -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
}
@@ -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)
+7 -9
View File
@@ -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 {
@@ -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(如果未含)
//