diff --git a/sdk/cliproxy/auth/conductor_stream.go b/sdk/cliproxy/auth/conductor_stream.go --- a/sdk/cliproxy/auth/conductor_stream.go +++ b/sdk/cliproxy/auth/conductor_stream.go @@ -71,6 +71,16 @@ } } +func validateStreamResult(result *cliproxyexecutor.StreamResult, err error) (*cliproxyexecutor.StreamResult, error) { + if err != nil { + return result, err + } + if result == nil || result.Chunks == nil { + return result, &Error{Code: "empty_stream", Message: "upstream stream has no source", Retryable: true} + } + return result, nil +} + func readStreamBootstrap(ctx context.Context, ch <-chan cliproxyexecutor.StreamChunk) ([]cliproxyexecutor.StreamChunk, bool, error) { if ch == nil { return nil, true, nil @@ -253,9 +263,7 @@ return nil, errCancel } } - if errStream == nil && (streamResult == nil || streamResult.Chunks == nil) { - errStream = &Error{Code: "empty_stream", Message: "upstream stream has no source", Retryable: true} - } + streamResult, errStream = validateStreamResult(streamResult, errStream) if errStream != nil { rerr := resultErrorFromError(errStream) result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, Success: false, Error: rerr} @@ -295,6 +303,7 @@ publishSelectedAuthMetadata(execOpts.Metadata, auth) didRefreshOnUnauthorized = true retryStream, retryErr := executor.ExecuteStream(ctx, auth, execReq, execOpts) + retryStream, retryErr = validateStreamResult(retryStream, retryErr) if retryErr != nil { if errCtx := ctx.Err(); errCtx != nil { return nil, errCtx diff --git a/sdk/cliproxy/auth/home_unauthorized_refresh_test.go b/sdk/cliproxy/auth/home_unauthorized_refresh_test.go --- a/sdk/cliproxy/auth/home_unauthorized_refresh_test.go +++ b/sdk/cliproxy/auth/home_unauthorized_refresh_test.go @@ -3,6 +3,7 @@ import ( "context" "encoding/json" + "errors" "net/http" "sync/atomic" "testing" @@ -44,6 +45,8 @@ keepStale bool retainSelection bool requirePrepared bool + nilRetryStream bool + nilRetryChunks bool executeCalls atomic.Int32 countCalls atomic.Int32 streamCalls atomic.Int32 @@ -90,6 +93,12 @@ } if e.requirePrepared && auth.Metadata["project_id"] != "prepared-project" { return nil, &Error{HTTPStatus: http.StatusBadRequest, Message: "missing prepared auth"} + } + if e.nilRetryStream { + return nil, nil + } + if e.nilRetryChunks { + return &cliproxyexecutor.StreamResult{}, nil } chunks := make(chan cliproxyexecutor.StreamChunk, 1) chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("ok")} @@ -359,6 +368,45 @@ } if got := executor.streamCalls.Load(); got != 2 { t.Fatalf("stream calls = %d, want initial attempt and one retry", got) + } +} + +func TestHomeUnauthorizedBootstrapRetryRejectsEmptyStream(t *testing.T) { + for _, test := range []struct { + name string + nilRetryStream bool + nilRetryChunks bool + }{ + {name: "nil result", nilRetryStream: true}, + {name: "nil chunks", nilRetryChunks: true}, + } { + t.Run(test.name, func(t *testing.T) { + dispatcher := &homeUnauthorizedRefreshDispatcher{} + executor := &homeUnauthorizedRefreshExecutor{ + streamMode: "bootstrap", + nilRetryStream: test.nilRetryStream, + nilRetryChunks: test.nilRetryChunks, + } + manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) + + result, errStream := manager.ExecuteStream(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{Stream: true}) + if errStream != nil { + t.Fatalf("ExecuteStream() error = %v", errStream) + } + var streamErr error + for chunk := range result.Chunks { + if chunk.Err != nil { + streamErr = chunk.Err + } + } + var authErr *Error + if !errors.As(streamErr, &authErr) || authErr.Code != "empty_stream" { + t.Fatalf("stream error = %#v, want empty_stream", streamErr) + } + if got := executor.streamCalls.Load(); got != 2 { + t.Fatalf("stream calls = %d, want initial attempt and one retry", got) + } + }) } }