Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
3f33ed1
fix(auth): harden failover recovery
Aug 12, 2026
c5f0574
fix(auth): bind concurrent affinity picks
Aug 12, 2026
2776041
test(auth): cover Responses discriminator
Aug 12, 2026
5ea708e
fix(auth): quarantine all session aliases
Aug 12, 2026
0754a19
fix(auth): parse newline-less SSE frames
Aug 12, 2026
65a7364
fix(translator): stabilize Gemini tool call IDs
Aug 13, 2026
28668d0
fix(auth): preserve failover state across retries
Aug 13, 2026
3744665
fix(stream): classify split empty completions
Aug 13, 2026
3272308
fix(stream): stop TTFT on upstream activity
Aug 13, 2026
2fdfb1e
fix(auth): forward passthrough headers through route exhaustion
Aug 13, 2026
5548eaa
fix(stream): restart a fresh TTFT timer and path on refresh retry
Aug 13, 2026
e84dc79
fix(auth): forward safe response headers through route exhaustion
Aug 13, 2026
286649a
fix(auth): classify upstream 401/403 as credential faults and preserv…
Aug 13, 2026
53be13a
refactor(auth): reuse retry cleanup helpers
Aug 13, 2026
56985a0
fix(openai): surface errors before stream DONE
Aug 13, 2026
6bc6cd6
fix(openai): sanitize all terminal errors
Aug 13, 2026
12e2d79
refactor(handlers): remove unused StreamFirstChunkTimeout helper
Aug 13, 2026
d00180d
fix(openai): preserve trusted direct responses
Aug 14, 2026
da0e59c
fix(openai): rebuild WebSocket terminal errors
Aug 14, 2026
012f084
test(openai): cover recoverable WebSocket errors
Aug 14, 2026
f813302
fix(auth): rebind full alias group on unavailable cached-auth recovery
Aug 14, 2026
0a1ee16
test(openai): add non-terminal websocket error raw-secret sanitizer r…
Aug 14, 2026
570dfd7
fix(auth,openai): implement alias-group generation CAS and remove str…
Aug 14, 2026
06755fc
fix(openai): preserve safe Retry-After headers on sealed stream errors
Aug 14, 2026
c2f96c7
fix(gemini): surface pending stream error when closed stream has no data
Aug 15, 2026
26ca11b
fix(auth): reset recovered cooldown exclusions before retry
Aug 15, 2026
5ed8b98
fix(auth): mirror of CLIProxyAPI PR #4881 follow-up (codex pullreques…
Aug 15, 2026
64d29b3
fix(auth): mirror of CLIProxyAPI c9de2f27 — preserve caller exclusion…
Aug 15, 2026
030d586
fix(selector): mirror of CLIProxyAPI 7831183d — eligible-only cooldow…
Aug 15, 2026
b863aff
fix(stream): mirror of CLIProxyAPI 59351e57 — zero-payload streams tr…
Aug 15, 2026
9257c02
fix(empty-completion): mirror of CLIProxyAPI 96911ecf — empty choices…
Aug 15, 2026
c5ae40d
fix(selector): reconcile split affinity groups onto the fallback auth…
Aug 15, 2026
a2a89d9
fix(selector): merge split affinity groups into a single rebound group
Aug 15, 2026
93c4a28
fix(selector): guard fallback group delete with observed generation
Aug 15, 2026
d18282c
fix(auth): validate Gemini part payloads before treating them as output
Aug 15, 2026
3905197
fix(auth): mark zero-choices OpenAI non-stream bodies terminal
Aug 15, 2026
9a01cd0
fix(selector): retain fallback aliases until the primary CAS commits
Aug 15, 2026
431e720
fix(auth): forward OpenAI choices when shape decoding fails
Aug 15, 2026
c54da99
fix(auth): ignore empty refusal values when judging output
Aug 15, 2026
dada152
fix(auth): retry non-stream responses that contain no payload
Aug 15, 2026
2e4ec86
test(amp): widen cache TTL margins in multi-source secret test
Aug 15, 2026
60932a9
fix(auth): rotate on dead Gemini API key (400 INVALID_ARGUMENT)
Aug 15, 2026
0c18975
ci: retrigger build (flake TestGetPluginSyncCancellationInterruptsRea…
Aug 15, 2026
4ff2867
fix(auth): treat whitespace-padded empty JSON and legacy choice text …
Aug 15, 2026
62b9095
fix(auth): quarantine invalid API keys across all models
Aug 15, 2026
caf42da
fix(auth): stop TTFT deadline once the stream is established
Aug 15, 2026
d9a0165
fix(auth): do not count Gemini thought flag as content
Aug 15, 2026
368fce5
fix(auth): treat SSE id and retry fields as bootstrap metadata
Aug 15, 2026
1f6c4e6
fix(auth): join multiline SSE data fields before decoding JSON
Aug 15, 2026
b226470
fix(auth): flush SSE data only at blank event boundaries
Aug 15, 2026
5a35239
fix(auth): fail metadata-only SSE streams at EOF
Aug 15, 2026
fca88f7
docs(config): describe stream-first-chunk timeout as connection estab…
Aug 15, 2026
86f2140
fix(auth): treat Responses completed events as valid terminal complet…
Aug 15, 2026
c5120c1
fix(auth): stop the stream timer before refreshing credentials
Aug 15, 2026
85a568e
test(auth): cover lost-rebind fallback group selection
Aug 15, 2026
efb1f08
test: harden racy test stubs under -race
Aug 15, 2026
b3f25ce
fix(auth): restore fallback aliases when split-group merge exhausts r…
Aug 15, 2026
69f1a42
test(auth): codex-responses terminal frames are never empty completions
Aug 15, 2026
aeced63
fix(auth): restore fallback aliases only when still absent
Aug 15, 2026
5246f86
fix(auth): validate tool-call entries before accepting output
Aug 15, 2026
d011dc1
fix(auth): forward blocked terminal frames and classify empty data ev…
Aug 15, 2026
26e8fa0
fix(auth): drain streams dropped by the refresh timeout race
Aug 15, 2026
0d4106a
test(executor): isolate xAI stream subtest idStore
Aug 15, 2026
b116df6
fix(auth): restore each still-free fallback alias independently
Aug 15, 2026
92b2a37
fix(auth): treat SSE metadata values as opaque
Aug 15, 2026
0d96c64
fix(auth): wait for a real SSE line boundary before field parsing
Aug 15, 2026
cb221fd
fix(auth): harden empty-completion detection at protocol edges
Aug 15, 2026
0ae1a52
test: give success-path fixtures real content events
Aug 15, 2026
1a1e710
fix(auth): treat Claude tool_use as an ordinary terminal reason
Aug 15, 2026
2712e52
fix(auth): detect SSE at line starts and validate Claude input deltas
Aug 15, 2026
f271a3b
fix(home): unblock plugin sync cancellation read
Aug 15, 2026
5c3a58d
fix(executor): invalidate codex websocket connection on terminal erro…
Aug 15, 2026
ac6669d
fix(auth): do not treat empty Claude thinking blocks as output
Aug 15, 2026
7fb18da
fix(auth): bypass stream bootstrap in downstream websocket mode
Aug 15, 2026
9d13070
fix(auth): preserve redacted Claude thinking payloads
Aug 15, 2026
8d6e75e
fix(auth): forward bootstrap error chunks instead of bypassing bootstrap
Aug 15, 2026
89e6174
fix(auth): validate Gemini function calls before accepting output
Aug 15, 2026
08a9ee3
fix(executor): invalidate failed websocket before releasing its lock
Aug 15, 2026
740cc75
fix(auth): retry errors that follow only bootstrap scaffolding
Aug 16, 2026
706c947
test(auth): export HasMeaningfulOutput; align codex terminal fixture
Aug 16, 2026
16cad13
fix(sdk/cliproxy/auth): recognize mcp_tool_use blocks before declarin…
Aug 16, 2026
02d4288
fix(sdk/cliproxy/auth): retain config-disabled credentials in retry e…
Aug 16, 2026
439203b
ci: retrigger after websocket bind-failure flake (xAI_stream close 10…
Aug 16, 2026
02d5fb5
fix(sdk/cliproxy/auth): harden emptiness detection for signatures, re…
Aug 16, 2026
bd6780b
fix(sdk/cliproxy/auth): drain pre-refresh stream and preserve Gemini …
Aug 16, 2026
17248ca
fix(sdk/cliproxy/auth): forward positive-usage frames, keep empty rea…
Aug 16, 2026
3de557a
fix(sdk/api/handlers/openai): redact lowercase-only bearer tokens in …
Aug 16, 2026
2f7ab80
fix(sdk): semantic Claude arg deltas, structured fault preservation, …
Aug 16, 2026
a60f0fd
fix(sdk): context-based credential masking, empty-stream [DONE] failover
Aug 16, 2026
133a335
fix(sdk): unconditional credential masking, empty Responses call-item…
Aug 16, 2026
6018539
fix(sdk): scope stream timeout to connection establishment, validate …
Aug 16, 2026
f7a7dfa
fix(sdk): semantic OpenAI argument emptiness, credential-scoped inval…
Aug 16, 2026
2d25a15
fix(sdk,executor): terminal WS events, message_stop bootstrap short-c…
Aug 16, 2026
7c58d63
fix(pluginhost,sdk): terminal-empty stops for direct plugin streams a…
Aug 16, 2026
fb48147
fix(sdk): recognize data-only Claude message_stop as terminal
Aug 16, 2026
882a504
fix(sdk,pluginhost): resume all models after credential recovery, dra…
Aug 16, 2026
90f5708
fix(sdk): drain upstream source on terminal-empty exits in conductor …
Aug 16, 2026
41bc749
test(sdk): red-proof drain tests for terminal-empty exits
Aug 16, 2026
2687f5a
fix(sdk): preserve image-only completions, resume sibling models only…
Aug 16, 2026
8ca4ddc
fix(sdk): mask camelCase credential keys, treat Claude citation delta…
Aug 16, 2026
f4b66bf
fix(sdk): count image output, preserve top-level WS error fields
Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ nonstream-keepalive-interval: 0
# streaming:
# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
# bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
# stream-connect-timeout-seconds: 20 # Default: 0 (disabled). Optional maximum wait for connection/stream establishment before failover. (Deprecated alias: stream-first-chunk-timeout-seconds).

# Signature cache validation for thinking blocks (Antigravity/Claude).
# When true (default), cached signatures are preferred and validated.
Expand Down
4 changes: 2 additions & 2 deletions internal/api/modules/amp/secret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func TestMultiSourceSecret_CacheBehavior(t *testing.T) {
t.Fatal(err)
}

s := NewMultiSourceSecretWithPath("", p, 50*time.Millisecond)
s := NewMultiSourceSecretWithPath("", p, 500*time.Millisecond)

// First read - should return v1
got1, err := s.Get(ctx)
Expand All @@ -90,7 +90,7 @@ func TestMultiSourceSecret_CacheBehavior(t *testing.T) {
}

// After TTL expires, should see v2
time.Sleep(60 * time.Millisecond)
time.Sleep(600 * time.Millisecond)
got3, _ := s.Get(ctx)
if got3 != "v2" {
t.Fatalf("cache miss expected v2, got %s", got3)
Expand Down
45 changes: 45 additions & 0 deletions internal/clienterror/client_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ func IsRequestFault(status int, err error) bool {
status = statusErr.StatusCode()
}
}
// Payment and rate-limit statuses are authoritative even when an upstream
// pairs them with a generic invalid_request_error body. The credential must
// remain eligible for cooldown and rotation.
if status == http.StatusPaymentRequired || status == http.StatusTooManyRequests {
return false
}
// Authentication and invalid-or-expired-credential failures are caused by
// the credential, not the request: they must remain eligible for rotation
// by the shared mixed-auth loop even when the provider pairs them with a
// generic invalid-request identifier in the body. This must be checked
// before hasRequestFaultBody so the generic classifier cannot misfile them.
if (status == http.StatusUnauthorized || status == http.StatusForbidden) && hasAuthenticationErrorBody(err) {
return false
}
if hasRequestFaultBody(err) {
return true
}
Expand Down Expand Up @@ -112,6 +126,37 @@ func IsItemNotPersisted(message string) bool {
strings.Contains(lower, "items are not persisted when `store` is set to false")
}

// hasAuthenticationErrorBody reports whether err is a structured credential
// failure: an authentication_error type, an invalid or expired API-key code,
// or a Gemini UNAUTHENTICATED status. These are credential faults, not request
// faults, so they must never be classified as request faults on 401/403.
func hasAuthenticationErrorBody(err error) bool {
if err == nil {
return false
}
body := strings.TrimSpace(err.Error())
if body == "" || !json.Valid([]byte(body)) {
return false
}
for _, path := range []string{"error.code", "code", "response.error.code", "body.error.code"} {
switch strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String())) {
case "invalid_api_key", "incorrect_api_key", "expired_api_key":
return true
}
}
for _, path := range []string{"error.type", "type", "response.error.type", "body.error.type"} {
if errType := strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String())); errType == "authentication_error" {
return true
}
}
for _, path := range []string{"error.status", "status", "response.error.status", "body.error.status"} {
if status := strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String())); status == "unauthenticated" {
return true
}
}
return false
}

func hasRequestFaultBody(err error) bool {
if err == nil {
return false
Expand Down
119 changes: 119 additions & 0 deletions internal/clienterror/client_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
)

Expand Down Expand Up @@ -195,6 +196,124 @@ func TestIsRequestFault(t *testing.T) {
{name: "transport", status: http.StatusBadGateway, err: errors.New("unexpected EOF")},
{name: "invalid JSON body", status: http.StatusBadGateway, err: errors.New(`{"error":`)},
{name: "nil", status: 0},
{
// DeepSeek-style: 401 with authentication_error type plus the generic
// invalid_request_error code. Credential fault, not a request fault.
name: "401 authentication_error body",
status: http.StatusUnauthorized,
err: errors.New(`{"error":{"message":"Authentication Fails","type":"authentication_error","code":"invalid_request_error"}}`),
},
{
name: "403 authentication_error body",
status: http.StatusForbidden,
err: errors.New(`{"error":{"type":"authentication_error","message":"Invalid token."}}`),
},
{
name: "401 invalid_api_key code",
status: http.StatusUnauthorized,
err: errors.New(`{"error":{"message":"Invalid API key","type":"invalid_request_error","code":"invalid_api_key"}}`),
},
{
name: "403 incorrect_api_key code",
status: http.StatusForbidden,
err: errors.New(`{"error":{"code":"incorrect_api_key","message":"bad key"}}`),
},
{
name: "401 gemini unauthenticated status",
status: http.StatusUnauthorized,
err: errors.New(`{"error":{"code":16,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}`),
},
{
// Authentication marker with an invalid-request-type body: the auth
// carve-out must win over the generic classifier.
name: "401 auth marker beats generic invalid_request",
status: http.StatusUnauthorized,
err: errors.New(`{"error":{"type":"invalid_request","code":"invalid_api_key"}}`),
},
{
// Same body on a non-auth status stays a request fault.
name: "authentication marker only honored on 401/403",
status: http.StatusBadRequest,
err: errors.New(`{"error":{"type":"invalid_request","code":"invalid_api_key"}}`),
want: true,
},
{
// An auth-styled body behind a server status is not a request fault.
name: "authentication_error body behind upstream error",
status: http.StatusInternalServerError,
err: errors.New(`{"error":{"type":"authentication_error","message":"server glitch"}}`),
},
{
// Wrapped status code: the 401 is extracted via errors.As, and even a
// non-splittable (prefixed) body stays a non-request credential fault.
name: "wrapped 401 authentication_error",
err: fmt.Errorf("upstream: %w", statusError{status: http.StatusUnauthorized, body: `{"error":{"type":"authentication_error"}}`}),
},
{
name: "wrapped 400 invalid request stays request fault",
err: fmt.Errorf("upstream: %w", statusError{status: http.StatusBadRequest, body: "bad input"}),
want: true,
},
{
// statusCoder form: the error carries both the 401 and the structured
// auth body directly (injected status stays 0, so it is read off the
// error). This mirrors how the shared loop feeds a Result error.
name: "statusCoder auth body",
err: statusError{status: http.StatusUnauthorized, body: `{"error":{"type":"authentication_error","code":"invalid_api_key"}}`},
},
{
// Negative: a statusCoder 400 with an auth-looking body is still a
// request fault once status wins over the body.
name: "statusCoder 400 auth body still request fault",
err: statusError{status: http.StatusBadRequest, body: `{"error":{"type":"authentication_error"}}`},
want: true,
},
{
// Large but valid bodies are still classified safely.
name: "large authentication_error body",
status: http.StatusForbidden,
err: errors.New(`{"error":{"type":"authentication_error","message":"` + strings.Repeat("x", 1<<16) + `"}}`),
},
{
name: "429 stays credential domain",
status: http.StatusTooManyRequests,
err: errors.New(`{"error":{"type":"rate_limit_error","message":"slow down"}}`),
},
{
// CPA parity: a rate-limit status is authoritative even when the body
// carries a generic invalid_request_error code. Quota, not request.
name: "429 with generic invalid_request_error code stays quota",
status: http.StatusTooManyRequests,
err: errors.New(`{"error":{"code":"invalid_request_error","message":"Rate Limit Reached","param":null,"type":"unknown_error"}}`),
},
{
// CPA parity: a payment-required status is authoritative even when the
// body carries the generic invalid_request_error code. Quota/balance,
// not request.
name: "402 with generic invalid_request_error code stays payment",
status: http.StatusPaymentRequired,
err: errors.New(`{"error":{"message":"Insufficient Balance","type":"unknown_error","param":null,"code":"invalid_request_error"}}`),
},
{
// A 402 surface with the invalid_request_error type spelling is still
// payment-scoped, never a request fault.
name: "402 with invalid_request_error type stays payment",
status: http.StatusPaymentRequired,
err: errors.New(`{"error":{"type":"invalid_request_error","code":"insufficient_balance","message":"low balance"}}`),
},
{
// statusCoder form: quota/payment status is read off the error and the
// precedence still wins over the generic body code.
name: "statusCoder 429 with generic code stays quota",
err: statusError{status: http.StatusTooManyRequests, body: `{"error":{"code":"invalid_request_error","message":"quota"}}`},
},
{
// A 429 that is genuinely a 429 request-fault-looking body never flips
// to a request fault either, because the status stays authoritative.
name: "429 status stays authoritative over auth-looking body",
status: http.StatusTooManyRequests,
err: errors.New(`{"error":{"type":"authentication_error","code":"invalid_request_error"}}`),
},
}

for _, tc := range tests {
Expand Down
7 changes: 7 additions & 0 deletions internal/config/sdk_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,11 @@ type StreamingConfig struct {
// to allow auth rotation / transient recovery.
// <= 0 disables bootstrap retries. Default is 0.
BootstrapRetries int `yaml:"bootstrap-retries,omitempty" json:"bootstrap-retries,omitempty"`

// StreamConnectTimeoutSeconds controls the maximum time to wait for connection/stream establishment from an upstream stream before timing out and failing over.
// <= 0 disables stream connect timeout. Default is 0.
StreamConnectTimeoutSeconds int `yaml:"stream-connect-timeout-seconds,omitempty" json:"stream-connect-timeout-seconds,omitempty"`

// StreamFirstChunkTimeoutSeconds is a deprecated alias for StreamConnectTimeoutSeconds.
StreamFirstChunkTimeoutSeconds int `yaml:"stream-first-chunk-timeout-seconds,omitempty" json:"stream-first-chunk-timeout-seconds,omitempty"`
}
5 changes: 2 additions & 3 deletions internal/home/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1618,9 +1618,8 @@ func newPluginSyncCancelableConn(ctx context.Context, conn net.Conn) net.Conn {
go func() {
select {
case <-ctx.Done():
if errDeadline := conn.SetDeadline(time.Now()); errDeadline != nil {
_ = conn.Close()
}
_ = conn.SetDeadline(time.Now())
_ = conn.Close()
case <-wrapped.done:
}
}()
Expand Down
5 changes: 5 additions & 0 deletions internal/interfaces/error_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ type ErrorMessage struct {
// DirectResponse reports that Body and Headers were explicitly supplied by a trusted in-process component.
DirectResponse bool

// TrustedDirectResponse reports that a DirectResponse originated locally
// (plugin/interceptor) and is safe to preserve through OpenAI sanitizers.
// Zero value false means the DirectResponse must be treated as untrusted.
TrustedDirectResponse bool

// Body contains a preformatted downstream response when DirectResponse is true.
Body []byte

Expand Down
48 changes: 37 additions & 11 deletions internal/pluginhost/executor_route.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,17 +159,28 @@ func wrapStreamEmptyCompletion(ctx context.Context, streamResult *coreexecutor.S
case chunk, ok = <-src:
}
if !ok {
if !forwarding && len(buffered) == 0 {
_ = forward(coreexecutor.StreamChunk{Err: &coreauth.Error{
Code: "empty_stream",
Message: "upstream stream closed before first payload",
Retryable: true,
}})
return
}
if !forwarding && coreauth.IsEmptyCompletionPayload(streamChunkPayload(buffered)) {
_ = forward(coreexecutor.StreamChunk{Err: coreauth.EmptyCompletionError()})
return
if !forwarding {
payloadBytes := 0
for _, c := range buffered {
payloadBytes += len(c.Payload)
}
if payloadBytes == 0 {
// Zero-payload chunks are dropped downstream; a stream of only
// such chunks is an empty stream, not a successful completion.
_ = forward(coreexecutor.StreamChunk{Err: &coreauth.Error{
Code: "empty_stream",
Message: "upstream stream closed before first payload",
Retryable: true,
}})
return
}
// Judge with the incremental detector state instead of re-parsing
// the concatenated payload: separately chunked SSE frames do not
// reassemble into valid input for the payload-level check.
if detector.Finish() {
_ = forward(coreexecutor.StreamChunk{Err: coreauth.EmptyCompletionError()})
return
}
}
_ = flush()
return
Expand Down Expand Up @@ -202,11 +213,26 @@ func wrapStreamEmptyCompletion(ctx context.Context, streamResult *coreexecutor.S
return
}
}
if detector.IsTerminalEmpty() {
discardStreamChunks(src)
_ = forward(coreexecutor.StreamChunk{Err: coreauth.EmptyCompletionError()})
return
}
}
}()
return &coreexecutor.StreamResult{Chunks: wrapped, Headers: streamResult.Headers}
}

func discardStreamChunks(ch <-chan coreexecutor.StreamChunk) {
if ch == nil {
return
}
go func() {
for range ch {
}
}()
}

func streamChunkPayload(chunks []coreexecutor.StreamChunk) []byte {
var payload []byte
for _, chunk := range chunks {
Expand Down
67 changes: 67 additions & 0 deletions internal/pluginhost/executor_route_stream_codex_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package pluginhost

import (
"context"
"errors"
"testing"

coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)

// Regression tests mirrored from CLIProxyAPI PR #4881 follow-up
// (codex pullrequestreview-4943660625, pluginhost stream wrapper EOF
// handling).

// TestWrapStreamEmptyCompletionRejectsZeroPayloadChunkStream is a regression
// guard for the zero-payload finding: zero-payload chunks made the buffer
// non-empty, so the EOF branch skipped the empty_stream error and flushed a
// client-invisible stream as success.
func TestWrapStreamEmptyCompletionRejectsZeroPayloadChunkStream(t *testing.T) {
src := make(chan coreexecutor.StreamChunk, 2)
src <- coreexecutor.StreamChunk{Payload: nil}
src <- coreexecutor.StreamChunk{Payload: []byte{}}
close(src)

wrapped := wrapStreamEmptyCompletion(context.Background(), &coreexecutor.StreamResult{Chunks: src})
first, ok := <-wrapped.Chunks
if !ok {
t.Fatal("wrapped stream closed without empty_stream error")
}
var authErr *coreauth.Error
if !errors.As(first.Err, &authErr) || authErr.Code != "empty_stream" || !authErr.Retryable {
t.Fatalf("first error = %#v, want retryable empty_stream", first.Err)
}
if len(first.Payload) != 0 {
t.Fatalf("first payload = %q, want error before client-visible bytes", first.Payload)
}
if _, ok = <-wrapped.Chunks; ok {
t.Fatal("wrapped stream emitted chunks after empty_stream error")
}
}

// TestWrapStreamEmptyCompletionDetectsSplitUsageOnlyStream is a regression
// guard for the detector.Finish finding: the EOF branch used to re-parse the
// concatenated payload, and separately chunked SSE frames without trailing
// newlines concatenated into invalid input, so the empty check failed and an
// empty plugin stream was flushed as success. The incremental detector state
// now decides at EOF.
func TestWrapStreamEmptyCompletionDetectsSplitUsageOnlyStream(t *testing.T) {
src := make(chan coreexecutor.StreamChunk, 2)
src <- coreexecutor.StreamChunk{Payload: []byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":0}}")}
src <- coreexecutor.StreamChunk{Payload: []byte("data: [DONE]")}
close(src)

wrapped := wrapStreamEmptyCompletion(context.Background(), &coreexecutor.StreamResult{Chunks: src})
first, ok := <-wrapped.Chunks
if !ok {
t.Fatal("wrapped split usage-only stream closed without empty_completion error")
}
var authErr *coreauth.Error
if !errors.As(first.Err, &authErr) || authErr.Code != "empty_completion" {
t.Fatalf("first error = %v, want empty_completion", first.Err)
}
if len(first.Payload) != 0 {
t.Fatalf("first payload = %q, want no client-visible bytes before error", first.Payload)
}
}
Loading
Loading