Commit 139c7cb
fix(ccr): send Accept: application/json on a buffered stream:false turn (#3102)
## Description
Server-side CCR retrieval flips a `stream: true` turn to `stream: false`
so the whole upstream reply is in hand before answering. The **body**
was rewritten; the client's `Accept: text/event-stream` was **not**. The
request that went on the wire therefore contradicted itself — *"answer
as JSON"* in the body, *"I only accept SSE"* in the headers.
Anthropic's first-party API tolerates that, which is why this never
surfaced against it. GitHub Copilot's Anthropic-compatible gateway does
not, and answers with a generic `api_error`.
That is the reported shape exactly. An OpenCode session's **first** call
succeeds — no marker exists yet, so nothing is buffered. The **second**
call is the first to carry a redeemable `<<ccr:…>>` marker, so it is the
first to be flipped to buffered, and it fails. The reporter's own logs
show the correlation: every failed request carries
`mutation_reasons=…,ccr_streaming_retrieve_buffered_non_stream`.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/proxy/handlers/anthropic.py`: when the buffered CCR path
flips `stream` to `false`, the outgoing `Accept` header is set to
`application/json` to match. The lookup is case-insensitive and
**replaces** the existing header rather than appending, so exactly one
`Accept` goes upstream.
- `headroom/proxy/handlers/openai.py`: the **same fix on the
`/v1/responses` buffered path**, which has an identical `stream: false`
flip with no matching `Accept`. This handler is a GitHub Copilot path —
it calls `apply_copilot_api_auth` — so leaving it would have left the
reported bug live on a route the reporter can hit. Found during
self-review, not in the original diff.
- Same treatment for the Anthropic CCR continuation request, which is
non-streaming for the same reason and previously fixed only
`Content-Type`. Its header strip is now case-insensitive for
`Content-Type` as well, removing a latent duplicate-header path.
- `tests/test_buffered_ccr_accept_header.py`: 6 tests — the buffered
turn asks for JSON, exactly one `Accept` survives, mixed-case `Accept`
is replaced, a client sending no `Accept` still gets one, a non-buffered
streaming turn keeps `text/event-stream` untouched, and the OpenAI
`/v1/responses` buffered turn asks for JSON too.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
tests/test_buffered_ccr_accept_header.py ...... [100%]
6 passed
CCR-adjacent suites on this branch:
tests/test_buffered_ccr_accept_header.py, test_buffered_ccr_salvage.py,
test_buffered_ccr_grace_window.py, test_anthropic_streaming_ccr_retrieve.py,
test_ccr_buffered_stream_signed_thinking.py
41 passed
Full suite on this branch:
3 failed, 11216 passed, 581 skipped in 414.81s
```
The 3 failures are pre-existing and environmental, identical to a
plain-`main` baseline run on the same machine: no `cargo` installed
(`test_no_native_tls_in_wheel_build_tree`), no `codex` CLI
(`test_learn/test_integration.py`), and
`test_run_server_installs_cancelled_error_filter`, which fails under
full-suite ordering on `main` too.
## Real Behavior Proof
- Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off
`main` @ `7ef736fb`, `HEADROOM_SKIP_UPSTREAM_CHECK=1`
- Exact command / steps: Drove one streaming `/v1/messages` turn through
`create_app()` carrying a redeemable `<<ccr:…>>` marker and
`headroom_retrieve` in `tools` (so the buffered path engages), with the
client sending `Accept: text/event-stream`, and captured the exact
headers and body handed to the upstream call.
- Observed result: Before — `body.stream=False` sent together with
`accept: text/event-stream`, the self-contradicting request. After —
`body.stream=False` with `accept: application/json`, and a turn that is
not flipped still sends `accept: text/event-stream` unchanged. Reverting
only `headroom/proxy/handlers/anthropic.py` fails 3 of the new tests;
reverting the OpenAI hunk alone fails the `/v1/responses` test with
`['text/event-stream'] != ['application/json']`. Restoring both passes
all 6.
- Not tested: No live GitHub Copilot gateway call — I have no Copilot
credentials here, so the claim that Copilot rejects the contradictory
request is inferred from the reporter's logs plus the header mismatch,
not observed against their upstream. Confirmation from @mars-peng-lb on
a real OpenCode + Copilot session is still wanted before treating #3078
as fully closed.
Separately noted while reviewing, **not fixed here**:
`_should_buffer_openai_responses_stream_ccr` has no redeemable-marker
requirement, so the `/v1/responses` path still buffers on mere tool
presence — the #3071/#3092 narrowing was never mirrored from the
Anthropic handler. Worth its own issue.
## Runtime Rollout Safety
- Rollout-managed feature(s): None — no rollout channel gates this.
- Minimum rollout channel: n/a
- Stable/default behavior changed: Only on the buffered CCR path, and
only the `Accept` header, which is made consistent with the `stream:
false` body already being sent. Non-buffered turns are byte-identical,
pinned by a test.
- Kill switch / disable path: `--no-ccr` / `HEADROOM_NO_CCR` disables
the buffered path entirely (see #3082), as does
`ccr_handle_responses=False`.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert this commit; the buffered path returns to
forwarding the client's `Accept` unchanged.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
Closes #3078
Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>1 parent 131b119 commit 139c7cb
3 files changed
Lines changed: 266 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3501 | 3501 | | |
3502 | 3502 | | |
3503 | 3503 | | |
| 3504 | + | |
| 3505 | + | |
| 3506 | + | |
| 3507 | + | |
| 3508 | + | |
| 3509 | + | |
| 3510 | + | |
| 3511 | + | |
| 3512 | + | |
| 3513 | + | |
| 3514 | + | |
| 3515 | + | |
| 3516 | + | |
| 3517 | + | |
3504 | 3518 | | |
3505 | 3519 | | |
3506 | 3520 | | |
| |||
3903 | 3917 | | |
3904 | 3918 | | |
3905 | 3919 | | |
| 3920 | + | |
| 3921 | + | |
| 3922 | + | |
3906 | 3923 | | |
3907 | | - | |
3908 | | - | |
| 3924 | + | |
| 3925 | + | |
| 3926 | + | |
3909 | 3927 | | |
| 3928 | + | |
| 3929 | + | |
3910 | 3930 | | |
3911 | 3931 | | |
3912 | 3932 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5616 | 5616 | | |
5617 | 5617 | | |
5618 | 5618 | | |
| 5619 | + | |
| 5620 | + | |
| 5621 | + | |
| 5622 | + | |
| 5623 | + | |
| 5624 | + | |
5619 | 5625 | | |
5620 | 5626 | | |
5621 | 5627 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
0 commit comments