diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 3722ec5189..d5976c42b4 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -582,7 +582,8 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokensFiltersGenerationF require.Equal(t, "sys", gjson.GetBytes(sentBody, "system.0.text").String()) require.Equal(t, "hello", gjson.GetBytes(sentBody, "messages.0.content").String()) require.Equal(t, "tool", gjson.GetBytes(sentBody, "tools.0.name").String()) - require.Equal(t, int64(1024), gjson.GetBytes(sentBody, "max_tokens").Int()) + require.False(t, gjson.GetBytes(sentBody, "max_tokens").Exists(), + "count_tokens 请求不得携带生成参数 max_tokens") require.Equal(t, "enabled", gjson.GetBytes(sentBody, "thinking.type").String()) } diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index b0f2e5b820..bdeb2c600b 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -596,6 +596,35 @@ func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndTo "count_tokens 路径必须含 token-counting beta") } +func TestBuildCountTokensRequest_OAuthMimic_DropsInjectedMaxTokens(t *testing.T) { + // OAuth mimicry injects max_tokens=128000 for normal messages requests. It is + // invalid for Anthropic's count_tokens endpoint and must be stripped on wire. + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil) + + account := &Account{ID: 413, Platform: PlatformAnthropic, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "oauth-tok"}, + Status: StatusActive, Schedulable: true, + } + normalized, _ := normalizeClaudeOAuthRequestBody( + []byte(`{"model":"claude-sonnet-4-5","messages":[]}`), + "claude-sonnet-4-5", claudeOAuthNormalizeOptions{}, + ) + require.Equal(t, int64(128000), gjson.GetBytes(normalized, "max_tokens").Int(), + "precondition: OAuth mimicry injects the Claude Code default") + + svc := &GatewayService{cfg: &config.Config{}} + req, _, err := svc.buildCountTokensRequest( + context.Background(), c, account, normalized, + "oauth-tok", "oauth", "claude-sonnet-4-5", true, + ) + require.NoError(t, err) + require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "max_tokens").Exists(), + "count_tokens wire body must not contain max_tokens") +} + func TestBuildCountTokensRequest_APIKeyHaiku_StripsContextManagementEndToEnd(t *testing.T) { // API-key + haiku + 客户端 header 不带 context-management beta → final beta 不含 → strip gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 500f655660..7b66ac9596 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -584,6 +584,10 @@ func sanitizeCountTokensRequestBody(body []byte) []byte { "stream", "stop_sequences", "stop", + // Anthropic's /v1/messages/count_tokens accepts request-input fields only. + // max_tokens is a generation parameter; OAuth mimicry may inject it to + // resemble Claude Code messages requests, so it must never reach this endpoint. + "max_tokens", } { if gjson.GetBytes(out, path).Exists() { if next, ok := deleteJSONPathBytes(out, path); ok {