Ctrl K
ComunidadGuías…

test/codex_stream_disconnect_failover_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package test

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
	"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
	runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor"
	_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
	"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
	openaihandlers "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers/openai"
	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"
)

// TestCodexStreamDisconnectAfterHeadersFailsOver reproduces issue #6247:
// When upstream A sends HTTP 200 headers and closes the connection before emitting
// any SSE bytes, the gateway must fail over to credential B instead of aborting
// with invalid_request_error.
func TestCodexStreamDisconnectAfterHeadersFailsOver(t *testing.T) {
	for _, buffering := range []bool{false, true} {
		name := "unbuffered"
		if buffering {
			name = "buffered"
		}
		t.Run(name, func(t *testing.T) {
			const model = "gpt-6-astra"
			var mu sync.Mutex
			var attempts []string

			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				account := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
				mu.Lock()
				attempts = append(attempts, account)
				mu.Unlock()

				if account == "account-a" {
					w.Header().Set("Content-Type", "text/event-stream")
					w.WriteHeader(http.StatusOK)
					if flusher, ok := w.(http.Flusher); ok {
						flusher.Flush()
					}
					// Disconnect after headers but before any SSE bytes.
					return
				}

				// Account B completes the stream.
				w.Header().Set("Content-Type", "text/event-stream")
				w.WriteHeader(http.StatusOK)
				_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_b\",\"model\":%q}}\n\n", model)
				_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"msg_b\",\"type\":\"message\"}}\n\n")
				_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"item_id\":\"msg_b\",\"delta\":\"MOCK_B\"}\n\n")
				_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_b\",\"status\":\"completed\",\"output\":[]}}\n\n")
				if flusher, ok := w.(http.Flusher); ok {
					flusher.Flush()
				}
			}))
			defer server.Close()

			manager := cliproxyauth.NewManager(nil, &cliproxyauth.RoundRobinSelector{}, nil)
			manager.SetRetryConfig(0, 0, 2)
			cfg := &config.Config{}
			cfg.Codex.StreamBootstrapBuffering = buffering
			manager.RegisterExecutor(runtimeexecutor.NewCodexExecutor(cfg))

			const authA = "codex-failover-a"
			const authB = "codex-failover-b"
			registry.GetGlobalRegistry().RegisterClient(authA, "codex", []*registry.ModelInfo{{ID: model}})
			registry.GetGlobalRegistry().RegisterClient(authB, "codex", []*registry.ModelInfo{{ID: model}})
			t.Cleanup(func() {
				registry.GetGlobalRegistry().UnregisterClient(authA)
				registry.GetGlobalRegistry().UnregisterClient(authB)
			})

			if _, errRegister := manager.Register(context.Background(), &cliproxyauth.Auth{
				ID: authA, Provider: "codex", Status: cliproxyauth.StatusActive,
				Attributes: map[string]string{"priority": "100", "base_url": server.URL, "api_key": "account-a"},
				Metadata:   map[string]any{"disable_cooling": true},
			}); errRegister != nil {
				t.Fatal(errRegister)
			}
			if _, errRegister := manager.Register(context.Background(), &cliproxyauth.Auth{
				ID: authB, Provider: "codex", Status: cliproxyauth.StatusActive,
				Attributes: map[string]string{"priority": "0", "base_url": server.URL, "api_key": "account-b"},
				Metadata:   map[string]any{"disable_cooling": true},
			}); errRegister != nil {
				t.Fatal(errRegister)
			}

			// Test ExecuteStream directly via Conductor
			req := cliproxyexecutor.Request{
				Model:   model,
				Payload: []byte(fmt.Sprintf(`{"model":%q,"input":"hello","stream":true}`, model)),
			}
			opts := cliproxyexecutor.Options{
				Stream:          true,
				SourceFormat:    sdktranslator.FromString("openai-response"),
				ResponseFormat:  sdktranslator.FormatOpenAIResponse,
				OriginalRequest: req.Payload,
			}

			streamResult, errStream := manager.ExecuteStream(context.Background(), []string{"codex"}, req, opts)
			if errStream != nil {
				t.Fatalf("ExecuteStream failed instead of failing over: %v", errStream)
			}
			if streamResult == nil {
				t.Fatal("expected non-nil streamResult after failover")
			}

			var streamBody string
			for chunk := range streamResult.Chunks {
				if chunk.Err != nil {
					t.Fatalf("unexpected chunk error: %v", chunk.Err)
				}
				streamBody += string(chunk.Payload)
			}

			if !strings.Contains(streamBody, "MOCK_B") || !strings.Contains(streamBody, "response.completed") {
				t.Fatalf("stream body missing expected completion from B: %s", streamBody)
			}

			mu.Lock()
			gotAttempts := append([]string(nil), attempts...)
			mu.Unlock()

			if len(gotAttempts) != 2 || gotAttempts[0] != "account-a" || gotAttempts[1] != "account-b" {
				t.Fatalf("attempts = %v, want [account-a, account-b]", gotAttempts)
			}
		})
	}
}

// TestCodexStreamDisconnectExhaustionNotInvalidRequestError verifies that when
// retries are exhausted for a stream that disconnects before any SSE bytes,
// the surfaced JSON error is classified as an upstream/server error (not invalid_request_error).
func TestCodexStreamDisconnectExhaustionNotInvalidRequestError(t *testing.T) {
	const model = "gpt-6-astra"
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "text/event-stream")
		w.WriteHeader(http.StatusOK)
		if flusher, ok := w.(http.Flusher); ok {
			flusher.Flush()
		}
		// Close without any SSE data bytes.
	}))
	defer server.Close()

	manager := cliproxyauth.NewManager(nil, &cliproxyauth.RoundRobinSelector{}, nil)
	manager.SetRetryConfig(0, 0, 0)
	cfg := &config.Config{}
	manager.RegisterExecutor(runtimeexecutor.NewCodexExecutor(cfg))

	const authID = "codex-exhaustion-test"
	registry.GetGlobalRegistry().RegisterClient(authID, "codex", []*registry.ModelInfo{{ID: model}})
	t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(authID) })

	if _, errRegister := manager.Register(context.Background(), &cliproxyauth.Auth{
		ID: authID, Provider: "codex", Status: cliproxyauth.StatusActive,
		Attributes: map[string]string{"base_url": server.URL, "api_key": "dummy"},
		Metadata:   map[string]any{"disable_cooling": true},
	}); errRegister != nil {
		t.Fatal(errRegister)
	}

	gin.SetMode(gin.TestMode)
	recorder := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(recorder)
	c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses",
		strings.NewReader(fmt.Sprintf(`{"model":%q,"input":"hello","stream":true}`, model)))
	c.Request.Header.Set("Content-Type", "application/json")

	base := handlers.NewBaseAPIHandlers(&cfg.SDKConfig, manager)
	openaihandlers.NewOpenAIResponsesAPIHandler(base).Responses(c)

	body := recorder.Body.String()
	var errorResp struct {
		Error struct {
			Message string `json:"message"`
			Type    string `json:"type"`
			Code    string `json:"code"`
		} `json:"error"`
	}
	if errUnmarshal := json.Unmarshal([]byte(body), &errorResp); errUnmarshal != nil {
		t.Fatalf("failed to unmarshal JSON error response (body: %s): %v", body, errUnmarshal)
	}

	if errorResp.Error.Type == "invalid_request_error" {
		t.Fatalf("error.type = %q for pre-output disconnect: must not claim well-formed request is invalid", errorResp.Error.Type)
	}
	if errorResp.Error.Type != "server_error" {
		t.Fatalf("error.type = %q, want server_error", errorResp.Error.Type)
	}
}

// TestCodexStreamDisconnectAfterOutputDoesNotFailOver verifies that when upstream A
// disconnects AFTER emitting visible output tokens/deltas, it does NOT attempt credential B
// (replaying partial output is unsafe), and instead surfaces the incomplete stream error chunk.
func TestCodexStreamDisconnectAfterOutputDoesNotFailOver(t *testing.T) {
	for _, buffering := range []bool{false, true} {
		name := "unbuffered"
		if buffering {
			name = "buffered"
		}
		t.Run(name, func(t *testing.T) {
			const model = "gpt-6-astra"
			var mu sync.Mutex
			var attempts []string

			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				account := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
				mu.Lock()
				attempts = append(attempts, account)
				mu.Unlock()

				w.Header().Set("Content-Type", "text/event-stream")
				w.WriteHeader(http.StatusOK)
				// Emit initial chunks including output text delta
				_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_a\",\"model\":%q}}\n\n", model)
				_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"msg_a\",\"type\":\"message\"}}\n\n")
				_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"item_id\":\"msg_a\",\"delta\":\"MOCK_A_PARTIAL\"}\n\n")
				if flusher, ok := w.(http.Flusher); ok {
					flusher.Flush()
				}
				// Disconnect without response.completed
			}))
			defer server.Close()

			manager := cliproxyauth.NewManager(nil, &cliproxyauth.RoundRobinSelector{}, nil)
			manager.SetRetryConfig(0, 0, 2)
			cfg := &config.Config{}
			cfg.Codex.StreamBootstrapBuffering = buffering
			manager.RegisterExecutor(runtimeexecutor.NewCodexExecutor(cfg))

			const authA = "codex-partial-a"
			const authB = "codex-partial-b"
			registry.GetGlobalRegistry().RegisterClient(authA, "codex", []*registry.ModelInfo{{ID: model}})
			registry.GetGlobalRegistry().RegisterClient(authB, "codex", []*registry.ModelInfo{{ID: model}})
			t.Cleanup(func() {
				registry.GetGlobalRegistry().UnregisterClient(authA)
				registry.GetGlobalRegistry().UnregisterClient(authB)
			})

			if _, errRegister := manager.Register(context.Background(), &cliproxyauth.Auth{
				ID: authA, Provider: "codex", Status: cliproxyauth.StatusActive,
				Attributes: map[string]string{"priority": "100", "base_url": server.URL, "api_key": "account-a"},
				Metadata:   map[string]any{"disable_cooling": true},
			}); errRegister != nil {
				t.Fatal(errRegister)
			}
			if _, errRegister := manager.Register(context.Background(), &cliproxyauth.Auth{
				ID: authB, Provider: "codex", Status: cliproxyauth.StatusActive,
				Attributes: map[string]string{"priority": "0", "base_url": server.URL, "api_key": "account-b"},
				Metadata:   map[string]any{"disable_cooling": true},
			}); errRegister != nil {
				t.Fatal(errRegister)
			}

			req := cliproxyexecutor.Request{
				Model:   model,
				Payload: []byte(fmt.Sprintf(`{"model":%q,"input":"hello","stream":true}`, model)),
			}
			opts := cliproxyexecutor.Options{
				Stream:          true,
				SourceFormat:    sdktranslator.FromString("openai-response"),
				ResponseFormat:  sdktranslator.FormatOpenAIResponse,
				OriginalRequest: req.Payload,
			}

			streamResult, errStream := manager.ExecuteStream(context.Background(), []string{"codex"}, req, opts)
			if errStream != nil {
				t.Fatalf("unexpected ExecuteStream start error: %v", errStream)
			}

			var streamBody string
			var chunkErr error
			for chunk := range streamResult.Chunks {
				if chunk.Err != nil {
					chunkErr = chunk.Err
				}
				streamBody += string(chunk.Payload)
			}

			// Partial output MUST be delivered to downstream
			if !strings.Contains(streamBody, "MOCK_A_PARTIAL") {
				t.Fatalf("expected partial output MOCK_A_PARTIAL in stream body, got: %s", streamBody)
			}
			// Incomplete stream error MUST be delivered at the end
			if chunkErr == nil {
				t.Fatal("expected incomplete stream terminal chunk error, got nil")
			}

			mu.Lock()
			gotAttempts := append([]string(nil), attempts...)
			mu.Unlock()

			// Must ONLY attempt account-a, NOT fail over to account-b
			if len(gotAttempts) != 1 || gotAttempts[0] != "account-a" {
				t.Fatalf("attempts = %v, want [account-a] (partial output must not replay to B)", gotAttempts)
			}
		})
	}
}

// TestCodexStreamAbnormalDisconnectBeforeOutputFailsOver tests that abnormal connection
// termination (e.g. TCP reset / connection close via Hijacker) before output also fails over.
func TestCodexStreamAbnormalDisconnectBeforeOutputFailsOver(t *testing.T) {
	const model = "gpt-6-astra"
	var mu sync.Mutex
	var attempts []string

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		account := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
		mu.Lock()
		attempts = append(attempts, account)
		mu.Unlock()

		if account == "account-a" {
			w.Header().Set("Content-Type", "text/event-stream")
			w.WriteHeader(http.StatusOK)
			if flusher, ok := w.(http.Flusher); ok {
				flusher.Flush()
			}
			// Hijack and close TCP connection immediately to simulate abnormal drop.
			if hj, ok := w.(http.Hijacker); ok {
				if conn, _, errHijack := hj.Hijack(); errHijack == nil {
					_ = conn.Close()
					return
				}
			}
			return
		}

		// Account B completes the stream
		w.Header().Set("Content-Type", "text/event-stream")
		w.WriteHeader(http.StatusOK)
		_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_b\",\"model\":%q}}\n\n", model)
		_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_b\",\"status\":\"completed\",\"output\":[]}}\n\n")
		if flusher, ok := w.(http.Flusher); ok {
			flusher.Flush()
		}
	}))
	defer server.Close()

	manager := cliproxyauth.NewManager(nil, &cliproxyauth.RoundRobinSelector{}, nil)
	manager.SetRetryConfig(0, 0, 2)
	cfg := &config.Config{}
	manager.RegisterExecutor(runtimeexecutor.NewCodexExecutor(cfg))

	const authA = "codex-abnormal-a"
	const authB = "codex-abnormal-b"
	registry.GetGlobalRegistry().RegisterClient(authA, "codex", []*registry.ModelInfo{{ID: model}})
	registry.GetGlobalRegistry().RegisterClient(authB, "codex", []*registry.ModelInfo{{ID: model}})
	t.Cleanup(func() {
		registry.GetGlobalRegistry().UnregisterClient(authA)
		registry.GetGlobalRegistry().UnregisterClient(authB)
	})

	if _, errRegister := manager.Register(context.Background(), &cliproxyauth.Auth{
		ID: authA, Provider: "codex", Status: cliproxyauth.StatusActive,
		Attributes: map[string]string{"priority": "100", "base_url": server.URL, "api_key": "account-a"},
		Metadata:   map[string]any{"disable_cooling": true},
	}); errRegister != nil {
		t.Fatal(errRegister)
	}
	if _, errRegister := manager.Register(context.Background(), &cliproxyauth.Auth{
		ID: authB, Provider: "codex", Status: cliproxyauth.StatusActive,
		Attributes: map[string]string{"priority": "0", "base_url": server.URL, "api_key": "account-b"},
		Metadata:   map[string]any{"disable_cooling": true},
	}); errRegister != nil {
		t.Fatal(errRegister)
	}

	req := cliproxyexecutor.Request{
		Model:   model,
		Payload: []byte(fmt.Sprintf(`{"model":%q,"input":"hello","stream":true}`, model)),
	}
	opts := cliproxyexecutor.Options{
		Stream:          true,
		SourceFormat:    sdktranslator.FromString("openai-response"),
		ResponseFormat:  sdktranslator.FormatOpenAIResponse,
		OriginalRequest: req.Payload,
	}

	streamResult, errStream := manager.ExecuteStream(context.Background(), []string{"codex"}, req, opts)
	if errStream != nil {
		t.Fatalf("ExecuteStream failed instead of failing over: %v", errStream)
	}
	if streamResult == nil {
		t.Fatal("expected non-nil streamResult after failover")
	}

	var streamBody string
	for chunk := range streamResult.Chunks {
		if chunk.Err != nil {
			t.Fatalf("unexpected chunk error: %v", chunk.Err)
		}
		streamBody += string(chunk.Payload)
	}

	if !strings.Contains(streamBody, "response.completed") {
		t.Fatalf("stream body missing expected completion from B: %s", streamBody)
	}

	mu.Lock()
	gotAttempts := append([]string(nil), attempts...)
	mu.Unlock()

	if len(gotAttempts) != 2 || gotAttempts[0] != "account-a" || gotAttempts[1] != "account-b" {
		t.Fatalf("attempts = %v, want [account-a, account-b]", gotAttempts)
	}
}