mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 14:08:14 +08:00
Merge pull request #4428 from alfadb/fix/anthropic-haiku-full-mimicry
fix(gateway): 修复 Haiku OAuth 请求被归入额外用量
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(如果未含)
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user