Repository navigation
Description
This is separate from #6532 (tool_choice: none). It concerns response completion semantics. The behavior below is reproduced entirely against a local fake SSE server; it is not a claim that this caused the earlier reported production incident.
On v8.0.23, the Antigravity executor turns a clean HTTP EOF after partial text into a successful OpenAI Chat Completions terminal chunk, even though the upstream response contains neither finishReason nor a terminal event. A client then cannot distinguish this response from a completed answer.
This follows the separate completion-semantics discussion explicitly left out of #6357 / #6359. I also checked #5226: its compatibility case involves missing finish reasons on completed thinking/tool turns. The request here is to define and enforce a supported final-text completion contract without breaking that documented compatibility case.
Reproduction
Use AntigravityExecutor.ExecuteStream with OpenAI input/output formats and a local httptest.Server. Give it a synthetic, unexpired token and set auth.Attributes["base_url"] to that server. The server returns HTTP 200, Content-Type: text/event-stream, writes exactly this frame, and ends the HTTP response normally:
data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is "}]}}],"modelVersion":"gemini-3.7-flash"}}
The upstream does not send [DONE], finishReason, or usage. The actual executor emits the partial content followed by:
{"id":"","object":"chat.completion.chunk","created":0,"model":"gemini-3.7-flash","choices":[{"index":0,"delta":{},"finish_reason":"stop","native_finish_reason":"stop"}]}Collected result: text="The answer is ", finish_reason="stop", stream error nil.
Four local executor cases were run on v8.0.23:
| Fake upstream behavior | Actual downstream completion | Stream error |
|---|---|---|
| Partial text, no finish/usage, clean HTTP EOF | stop |
None |
| Partial text and usage, no finish, clean HTTP EOF | stop |
None |
Complete text with finishReason: STOP |
stop |
None |
| Same partial text, connection cut before the HTTP body ends | No finish reason | unexpected EOF |
The test transport allowed only the localhost server origin. No real account or provider request is needed.
Source evidence
- Executor EOF handling translates a synthetic
[DONE]whenever the scanner ends cleanly and the context remains active. - Translator completion synthesis requires
SawResponse, but does not require an upstream terminal signal. - Finish reason mapping defaults both finish reasons to
stopwhen the upstream reason is absent. - The existing SynthesizesStopOnDoneWhenFinishReasonAndUsageOmitted test explicitly expects this behavior; it passes on v8.0.23.
The executor file is byte-identical in v8.0.21 and v8.0.23. The translator difference between those releases changes token usage accounting only; the completion logic above is unchanged.
Expected behavior and proposed direction
Clean transport EOF should not manufacture successful text completion in the absence of a supported upstream terminal signal. Emit an explicit incomplete-stream error, or otherwise preserve the absence of completion so a strict client can reject the partial result. A recorded upstream finish reason can still be flushed when usage arrives separately or is omitted.
The terminal decision belongs in the upstream stream/translation owner. Keep authoritative upstream completion distinct from downstream delivery completion and generic SawResponse; do not rely on text inspection by clients. Usage metadata alone should not prove text completion.
There is an existing fixture described as a real tool-call response with usage but no finish reason. If that is an intentional Antigravity contract, preserve and document that specific contract while fixing partial final-text completion. Do not indiscriminately reject valid tool calls, or treat every non-empty upstream response as a successful final text answer.
Suggested regression coverage: partial text with clean EOF (with and without usage), normal finish with split/missing usage, empty body, transport read error, user cancellation, and the supported tool-call terminal contract. No client-side answer guessing or extra completion request is necessary.
Complete offline executor reproduction
Save as internal/runtime/executor/eof_public_repro_test.go in tag v8.0.23, then run:
go test ./internal/runtime/executor -run TestPublicReproCleanEOFWithoutUpstreamFinish -count=1 -vThis test characterizes the current behavior; a fixed implementation should change the two partial-text expectations.
package executor
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
"github.com/tidwall/gjson"
)
type eofReproLocalTransport struct {
baseURL string
base http.RoundTripper
}
func (r eofReproLocalTransport) RoundTrip(request *http.Request) (*http.Response, error) {
if request.URL.Scheme+"://"+request.URL.Host != r.baseURL {
return nil, fmt.Errorf("repro forbids non-local destination")
}
return r.base.RoundTrip(request)
}
// This characterization test reproduces the current false-success behavior;
// it is not a proposed regression expectation for the fixed implementation.
func TestPublicReproCleanEOFWithoutUpstreamFinish(t *testing.T) {
for _, tc := range []struct {
name string
body string
abrupt bool
wantStop bool
wantError bool
}{
{
name: "partial_text_then_clean_eof_no_finish_no_usage",
body: `data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is "}]}}],"modelVersion":"gemini-3.7-flash"}}` + "\n\n",
wantStop: true,
},
{
name: "partial_text_then_clean_eof_with_usage_no_finish",
body: `data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is "}]}}],"modelVersion":"gemini-3.7-flash","usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":3,"totalTokenCount":7}}}` + "\n\n",
wantStop: true,
},
{
name: "normal_upstream_stop",
body: `data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is 42."}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.7-flash","usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":5,"totalTokenCount":9}}}` + "\n\n",
wantStop: true,
},
{
name: "partial_text_then_transport_error",
body: `data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is "}]}}],"modelVersion":"gemini-3.7-flash"}}` + "\n\n",
abrupt: true,
wantError: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, tc.body)
w.(http.Flusher).Flush()
if tc.abrupt {
conn, _, err := w.(http.Hijacker).Hijack()
if err == nil {
_ = conn.Close()
}
}
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ctx = context.WithValue(ctx, "cliproxy.roundtripper", eofReproLocalTransport{
baseURL: server.URL, base: &http.Transport{Proxy: nil},
})
executor := NewAntigravityExecutor(&config.Config{})
result, err := executor.ExecuteStream(ctx, &cliproxyauth.Auth{
Metadata: map[string]any{
"access_token": "synthetic-local-test-token",
"expired": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"project_id": "synthetic-local-test-project",
},
Attributes: map[string]string{"base_url": server.URL},
}, cliproxyexecutor.Request{
Model: "gemini-3.7-flash",
Payload: []byte(`{"model":"gemini-3.7-flash","messages":[{"role":"user","content":"What is the answer?"}],"stream":true}`),
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatOpenAI,
ResponseFormat: sdktranslator.FormatOpenAI,
Stream: true,
})
if err != nil {
t.Fatal(err)
}
var text strings.Builder
var finishReason string
var streamError error
for chunk := range result.Chunks {
if chunk.Err != nil {
streamError = chunk.Err
continue
}
text.WriteString(gjson.GetBytes(chunk.Payload, "choices.0.delta.content").String())
if value := gjson.GetBytes(chunk.Payload, "choices.0.finish_reason").String(); value != "" {
finishReason = value
}
t.Logf("downstream=%s", chunk.Payload)
}
t.Logf("RESULT text=%q finish_reason=%q stream_error=%v", text.String(), finishReason, streamError)
if (finishReason == "stop") != tc.wantStop {
t.Fatalf("finish_reason=%q", finishReason)
}
if (streamError != nil) != tc.wantError {
t.Fatalf("stream_error=%v", streamError)
}
})
}
}
Activity
luispater commentedon Oct 12, 2026
Thanks for the detailed report and reproduction test.
The current clean-EOF synthesis behavior was intentionally introduced to maintain compatibility with upstream Antigravity streams that close cleanly without emitting an explicit
finishReason. We currently have no plans to change this logic.Closing this issue as not planned.
fix: thinking by model list, account failover, cut streams, missing c…