fix(antigravity): safely synthesize terminal finish reasons (#5230)
The Antigravity backend sometimes ends a 200 stream without ever emitting
finishReason. Evidence from local request logs: 25 of 18,346 captured
cloudcode-pa streams have no finishReason at all (gemini-3.7-flash x23,
gemini-3.6-flash x2). Gemini and OpenAI chat clients then never see a
terminal event and wait forever.
Only synthesize on a clean end of stream
- The [DONE] tail is now translated only when scanner.Err() is nil. A
truncated upstream stream previously still produced a terminal event:
replaying a cut stream to a Claude client emitted the full
content_block_stop / message_delta / message_stop sequence, so the
truncation was reported as a completed message.
- Only Antigravity translators synthesize on [DONE], so the other
executors that emit the tail before checking scanner.Err() cannot leak a
fake terminal event and are left unchanged.
Never finalize a stream that produced nothing
- Synthesis requires at least one chunk carrying candidates or token
accounting. Both translators share the same check, and presence alone is
not enough: `{}`, `{"response":{}}` and `{"response":{"candidates":[]}}`
leave the stream unstarted.
- Without that guard the synthetic chunk defeats the existing empty_stream
detection in sdk/cliproxy/auth/conductor_stream.go, which only fires when
the executor produced no chunk at all. An empty 200 would be reported as
a successful empty completion instead of a failure.
Synthetic chunks mirror the observed upstream shape
- All 18,321 real terminal chunks carry candidates/usageMetadata/
modelVersion/responseId with a model-role candidate whose parts are
[{"text":""}]. The Gemini synthetic chunk now reproduces that shape and
key order instead of a bare finishReason candidate.
- The last known usage snapshot is carried into the synthetic chunk.
Without it the final chunk a client sees reports no tokens, because
FilterSSEUsageMetadata renames non-terminal usage to cpaUsageMetadata
and the Gemini path restores it per chunk.
- The OpenAI chat path keeps the latest cpaUsageMetadata as pending usage
and emits it on [DONE] for the same reason.
Do not mistake an intermediate chunk for the terminal one
- A chunk carrying usage but no finishReason stays non-terminal.
FilterSSEUsageMetadata forwards real usageMetadata on such a chunk only
after an earlier chunk already carried finishReason, which the existing
condition covers; finalizing on usage alone would cut the stream short.
- finish_reason and native_finish_reason are resolved by one shared
helper, so the upstream terminal chunk and the synthesized [DONE] chunk
cannot drift apart.
- The non-stream Gemini conversion defaults a missing finishReason for
every candidate rather than only the first one.
Also fixes the unreachable alt != "" branch, which parsed an always-nil
buffer, and replaces an unchecked param type assertion.
Verified by replaying byte-exact upstream bodies extracted from request
logs through a mock backend, comparing this change against the unmodified
branch point: clean streams keep exactly one terminal event, streams
without finishReason gain one carrying the last usage snapshot, a stream
cut mid-chunk surfaces the read error with no terminal event, and an empty
200 now fails with empty_stream instead of reporting a successful empty
completion. S
sususu98 committed
998dcfeba2f1735999b3f54aac3c89ed0f945b36
Parent: f2b1996
Committed by GitHub <noreply@github.com>
on 8/25/2026, 7:13:04 AM