"Filter:" instead of "Index cond:" in index production usage
The planner does weigh between the options and chooses the one that it thinks will be cheaper, of course based on the estimates it has at hand at the time.
When having the ANY in the &quot;Index Cond&quot;, it needs to re-descend the index one time for each of the 19 members of your list.  Each time it re-descends, it thinks it will land on a different index leaf page costing one random_page_cost (a questionable assumption perhaps, but the alternative assumption would be just as questionable).  This leads to a high estimated cost.  It needs to pay this cost even for the members that don't end up matching to any index entry.
When the ANY is in the Filter, it thinks it will have to scan all rows meeting the provider_id = 38 AND connection_id = 1175966 condition, and apply the ANY to each one.  But the thing is, we have no way of knowing how many rows it thinks that that will be.  The EXPLAIN reports how many rows it thinks will survive the ANY filter, but doesn't report to us how many it thinks will be tested but fail (it has that estimate under the hood somewhere, it just doesn't make it visible to us).  EXPLAIN ANALYZE reports how many actually failed, but the planner can't plan retroactively based on that value of course.
It looks to me like your production server thinks only one row will have to be tested against the filter, which explains why it has such a low cost estimate and gets chosen.  Whereas your dev server has a more accurate estimate of how many rows need to be tested.  The way to test this hypothesis would be to run slightly different query and see what it gives for the row estimates on each machine.
explain SELECT * FROM reports WHERE &quot;provider_id&quot; = 38 AND &quot;connection_id&quot; = 1175966;

There is another possibility that the executor doesn't implement, which means the planner does not consider it.  It could scan the whole section of the index where &quot;provider_id&quot; = 38 AND &quot;connection_id&quot; = 1175966 and test the ANY against the id_on_site value stored in the index, rather than jumping to the table to test the id_on_site stored there.  I think the reason it does not do this is that it does not trust the ANY/IN test not to throw errors, so it is not going to risk testing it in the index and possibly throwing an error when that tuple in the table might be obsolete anyway.  No one would thank the PostgreSQL developers for making it throw errors based on the invisible detritus!  Now in this case it should be safe as it just a combination of = and OR, both of which are safe.  But I guess the system is not clever enough to know that.
So what can you do about it?  One thing would be fix the estimate of how many rows will match the provider_id = 38 AND connection_id = 1175966 condition on production (in which the first step would be finding out what that estimate is).  Another would be to get an index-only scan by adding more columns to the index or removing columns from the select list.  If you can get an index-only scan, it would test the ANY condition against the index value rather than the table value as long as the table page is marked all visible.  Another thing you could do is switch your RDS to a storage class which doesn't suck. It appears to be taking over 20ms per random page read, which as Erwin points out is horrible.