1116 lines
41 KiB
Go
1116 lines
41 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
internalhome "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
)
|
|
|
|
type rejectSecondBindLifecycle struct {
|
|
binds atomic.Int32
|
|
}
|
|
|
|
func (l *rejectSecondBindLifecycle) Bind(func() error) error {
|
|
if l.binds.Add(1) > 1 {
|
|
return fmt.Errorf("retry lifecycle bind rejected")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (*rejectSecondBindLifecycle) End(string) {}
|
|
|
|
func TestCodexWebsocketSessionActiveChannelBelongsToConnection(t *testing.T) {
|
|
sess := &codexWebsocketSession{}
|
|
oldConn := &websocket.Conn{}
|
|
newConn := &websocket.Conn{}
|
|
oldCh := make(chan codexWebsocketRead, 1)
|
|
newCh := make(chan codexWebsocketRead, 1)
|
|
|
|
sess.setActive(oldConn, oldCh)
|
|
if ch, _ := sess.activeForConn(oldConn); ch != oldCh {
|
|
t.Fatal("old connection did not own its active channel")
|
|
}
|
|
|
|
sess.setActive(newConn, newCh)
|
|
if sess.clearActive(oldConn, oldCh) {
|
|
t.Fatal("old connection cleared the new active channel")
|
|
}
|
|
if ch, _ := sess.activeForConn(oldConn); ch != nil {
|
|
t.Fatal("old connection retained access to an active channel")
|
|
}
|
|
if ch, _ := sess.activeForConn(newConn); ch != newCh {
|
|
t.Fatal("new connection lost its active channel")
|
|
}
|
|
if !sess.clearActive(newConn, newCh) {
|
|
t.Fatal("new connection could not clear its active channel")
|
|
}
|
|
|
|
closedOldCh := sess.activate(oldConn)
|
|
if !sess.clearActive(oldConn, closedOldCh) {
|
|
t.Fatal("old connection could not clear its active channel before retry")
|
|
}
|
|
close(closedOldCh)
|
|
retryCh := sess.activate(newConn)
|
|
if retryCh == closedOldCh {
|
|
t.Fatal("retry reused the old connection's read channel")
|
|
}
|
|
select {
|
|
case retryCh <- codexWebsocketRead{conn: newConn}:
|
|
default:
|
|
t.Fatal("retry read channel was not writable")
|
|
}
|
|
}
|
|
|
|
type trackedWebsocketLifecycle struct {
|
|
mu sync.Mutex
|
|
close func() error
|
|
once sync.Once
|
|
ends atomic.Int32
|
|
}
|
|
|
|
type drainDuringBindWebsocketLifecycle struct{}
|
|
|
|
func (drainDuringBindWebsocketLifecycle) Bind(closeFn func() error) error {
|
|
if errClose := closeFn(); errClose != nil {
|
|
return errClose
|
|
}
|
|
return fmt.Errorf("execution lifecycle drained during Bind")
|
|
}
|
|
|
|
func (drainDuringBindWebsocketLifecycle) End(string) {}
|
|
|
|
func (l *trackedWebsocketLifecycle) Bind(closeFn func() error) error {
|
|
l.mu.Lock()
|
|
l.close = closeFn
|
|
l.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (l *trackedWebsocketLifecycle) End(string) {
|
|
l.once.Do(func() {
|
|
l.ends.Add(1)
|
|
l.mu.Lock()
|
|
closeFn := l.close
|
|
l.mu.Unlock()
|
|
if closeFn != nil {
|
|
_ = closeFn()
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClearRetryActiveStateClearsOriginalConnection(t *testing.T) {
|
|
sess := &codexWebsocketSession{}
|
|
originalConn := &websocket.Conn{}
|
|
originalCh := sess.activate(originalConn)
|
|
if !clearRetryActiveState(sess, originalConn, originalCh) {
|
|
t.Fatal("clearRetryActiveState() = false, want true")
|
|
}
|
|
if ch, done := sess.activeForConn(originalConn); ch != nil || done != nil {
|
|
t.Fatalf("original active state = %v/%v, want nil", ch, done)
|
|
}
|
|
}
|
|
|
|
func TestWebsocketRetryBindFailureClearsActiveSessionState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func(t *testing.T, baseURL string) (func(cliproxyexecutor.Options) error, *codexWebsocketSession)
|
|
}{
|
|
{
|
|
name: "Codex nonstream",
|
|
run: func(t *testing.T, baseURL string) (func(cliproxyexecutor.Options) error, *codexWebsocketSession) {
|
|
executor := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
|
|
executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
auth := &cliproxyauth.Auth{ID: "retry-bind-codex", Provider: "codex", Attributes: map[string]string{"api_key": "test-key", "base_url": baseURL}}
|
|
req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"model":"gpt-5-codex","input":[{"type":"message","role":"user","content":"hello"}]}`)}
|
|
primed := false
|
|
return func(runOpts cliproxyexecutor.Options) error {
|
|
if !primed {
|
|
wsURL := "ws" + strings.TrimPrefix(baseURL, "http") + "/responses"
|
|
conn, _, _, errEnsure := executor.ensureUpstreamConn(context.Background(), auth, executor.getOrCreateSession("retry-bind"), auth.ID, wsURL, http.Header{})
|
|
if errEnsure != nil {
|
|
return errEnsure
|
|
}
|
|
if errDeadline := conn.SetWriteDeadline(time.Now().Add(-time.Second)); errDeadline != nil {
|
|
return errDeadline
|
|
}
|
|
primed = true
|
|
}
|
|
_, errExecute := executor.Execute(context.Background(), auth, req, runOpts)
|
|
return errExecute
|
|
}, executor.getOrCreateSession("retry-bind")
|
|
},
|
|
},
|
|
{
|
|
name: "Codex stream",
|
|
run: func(t *testing.T, baseURL string) (func(cliproxyexecutor.Options) error, *codexWebsocketSession) {
|
|
executor := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
|
|
executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
auth := &cliproxyauth.Auth{ID: "retry-bind-codex", Provider: "codex", Attributes: map[string]string{"api_key": "test-key", "base_url": baseURL}}
|
|
req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"model":"gpt-5-codex","input":[{"type":"message","role":"user","content":"hello"}]}`)}
|
|
primed := false
|
|
return func(runOpts cliproxyexecutor.Options) error {
|
|
if !primed {
|
|
wsURL := "ws" + strings.TrimPrefix(baseURL, "http") + "/responses"
|
|
conn, _, _, errEnsure := executor.ensureUpstreamConn(context.Background(), auth, executor.getOrCreateSession("retry-bind"), auth.ID, wsURL, http.Header{})
|
|
if errEnsure != nil {
|
|
return errEnsure
|
|
}
|
|
if errDeadline := conn.SetWriteDeadline(time.Now().Add(-time.Second)); errDeadline != nil {
|
|
return errDeadline
|
|
}
|
|
primed = true
|
|
}
|
|
result, errExecute := executor.ExecuteStream(context.Background(), auth, req, runOpts)
|
|
if errExecute != nil {
|
|
return errExecute
|
|
}
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
return chunk.Err
|
|
}
|
|
}
|
|
return nil
|
|
}, executor.getOrCreateSession("retry-bind")
|
|
},
|
|
},
|
|
{
|
|
name: "xAI stream",
|
|
run: func(t *testing.T, baseURL string) (func(cliproxyexecutor.Options) error, *codexWebsocketSession) {
|
|
executor := NewXAIWebsocketsExecutor(&config.Config{})
|
|
executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
auth := &cliproxyauth.Auth{ID: "retry-bind-xai", Provider: "xai", Attributes: map[string]string{"base_url": baseURL, "websockets": "true"}, Metadata: map[string]any{"access_token": "test-token"}}
|
|
req := cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(`{"model":"grok-4","input":[{"type":"message","role":"user","content":"hello"}]}`)}
|
|
primed := false
|
|
return func(runOpts cliproxyexecutor.Options) error {
|
|
if !primed {
|
|
wsURL := "ws" + strings.TrimPrefix(baseURL, "http") + "/responses"
|
|
conn, _, _, errEnsure := executor.ensureUpstreamConn(context.Background(), auth, executor.getOrCreateSession("retry-bind"), auth.ID, wsURL, http.Header{})
|
|
if errEnsure != nil {
|
|
return errEnsure
|
|
}
|
|
if errDeadline := conn.SetWriteDeadline(time.Now().Add(-time.Second)); errDeadline != nil {
|
|
return errDeadline
|
|
}
|
|
primed = true
|
|
}
|
|
result, errExecute := executor.ExecuteStream(context.Background(), auth, req, runOpts)
|
|
if errExecute != nil {
|
|
return errExecute
|
|
}
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
return chunk.Err
|
|
}
|
|
}
|
|
return nil
|
|
}, executor.getOrCreateSession("retry-bind")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
var connections atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, errUpgrade := upgrader.Upgrade(w, r, nil)
|
|
if errUpgrade != nil {
|
|
t.Errorf("upgrade websocket: %v", errUpgrade)
|
|
return
|
|
}
|
|
connection := connections.Add(1)
|
|
defer func() { _ = conn.Close() }()
|
|
if connection == 1 {
|
|
_, _, _ = conn.ReadMessage()
|
|
return
|
|
}
|
|
if connection == 2 {
|
|
return
|
|
}
|
|
if _, _, errRead := conn.ReadMessage(); errRead != nil {
|
|
return
|
|
}
|
|
completed := []byte(`{"type":"response.completed","response":{"id":"response-1","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
|
|
t.Errorf("write websocket completion: %v", errWrite)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
lifecycle := &rejectSecondBindLifecycle{}
|
|
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, ResponseFormat: sdktranslator.FormatOpenAIResponse, ExecutionLifecycle: lifecycle, Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "retry-bind"}}
|
|
run, sess := test.run(t, server.URL)
|
|
if errRun := run(opts); errRun == nil {
|
|
t.Fatal("first request error = nil, want retry lifecycle bind rejection")
|
|
}
|
|
if got := lifecycle.binds.Load(); got != 2 {
|
|
t.Fatalf("lifecycle binds = %d, want 2", got)
|
|
}
|
|
sess.activeMu.Lock()
|
|
active := sess.activeConn != nil || sess.activeCh != nil || sess.activeDone != nil || sess.activeCancel != nil
|
|
sess.activeMu.Unlock()
|
|
if active {
|
|
t.Fatal("retry bind failure left the old active websocket state")
|
|
}
|
|
|
|
opts.ExecutionLifecycle = nil
|
|
if errRun := run(opts); errRun != nil {
|
|
t.Fatalf("second request error = %v", errRun)
|
|
}
|
|
if got := connections.Load(); got != 3 {
|
|
t.Fatalf("websocket connections = %d, want 3 after retry bind failure", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebsocketSessionCloseEndsRetainedLifecycleOnce(t *testing.T) {
|
|
exec := NewCodexWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
server, closed := newWebsocketTargetServer(t)
|
|
defer server.Close()
|
|
|
|
sess := exec.getOrCreateSession("retained-lifecycle")
|
|
auth := &cliproxyauth.Auth{ID: "auth-a"}
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
|
conn := ensureWebsocketTargetConn(t, exec.ensureUpstreamConn, auth, sess, auth.ID, wsURL)
|
|
lifecycle := &trackedWebsocketLifecycle{}
|
|
if errBind := sess.bindExecutionLifecycle(cliproxyexecutor.Options{ExecutionLifecycle: lifecycle}, conn, sess.connCloser, "model-a"); errBind != nil {
|
|
t.Fatalf("bind execution lifecycle: %v", errBind)
|
|
}
|
|
|
|
exec.CloseExecutionSession("retained-lifecycle")
|
|
lifecycle.End("duplicate_close")
|
|
if got := lifecycle.ends.Load(); got != 1 {
|
|
t.Fatalf("lifecycle End calls = %d, want 1", got)
|
|
}
|
|
if got := <-closed; got != auth.ID {
|
|
t.Fatalf("closed server auth = %q, want %q", got, auth.ID)
|
|
}
|
|
}
|
|
|
|
type closeCountingNetConn struct {
|
|
net.Conn
|
|
closes atomic.Int32
|
|
}
|
|
|
|
func (c *closeCountingNetConn) Close() error {
|
|
c.closes.Add(1)
|
|
return c.Conn.Close()
|
|
}
|
|
|
|
func newCloseCountingWebsocketConn(t *testing.T, rawURL string) (*websocket.Conn, *closeCountingNetConn) {
|
|
t.Helper()
|
|
parsed, errParse := url.Parse(rawURL)
|
|
if errParse != nil {
|
|
t.Fatalf("parse websocket URL: %v", errParse)
|
|
}
|
|
conn, errDial := net.Dial("tcp", parsed.Host)
|
|
if errDial != nil {
|
|
t.Fatalf("dial websocket: %v", errDial)
|
|
}
|
|
counting := &closeCountingNetConn{Conn: conn}
|
|
wsConn, _, errClient := websocket.NewClient(counting, parsed, nil, 1024, 1024)
|
|
if errClient != nil {
|
|
_ = counting.Close()
|
|
t.Fatalf("create websocket client: %v", errClient)
|
|
}
|
|
return wsConn, counting
|
|
}
|
|
|
|
func TestSessionlessWebsocketSelectionEndAndDirectCloseRaceClosesOnce(t *testing.T) {
|
|
server, _ := newWebsocketTargetServer(t)
|
|
defer server.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
|
conn, physical := newCloseCountingWebsocketConn(t, wsURL)
|
|
closer := newWebsocketConnectionCloser(conn)
|
|
lifecycle := &trackedWebsocketLifecycle{}
|
|
if errBind := (*codexWebsocketSession)(nil).bindExecutionLifecycle(cliproxyexecutor.Options{ExecutionLifecycle: lifecycle}, conn, closer, "model-a"); errBind != nil {
|
|
t.Fatalf("bind sessionless lifecycle: %v", errBind)
|
|
}
|
|
|
|
var wait sync.WaitGroup
|
|
wait.Add(2)
|
|
go func() {
|
|
defer wait.Done()
|
|
lifecycle.End("selection_ended")
|
|
}()
|
|
go func() {
|
|
defer wait.Done()
|
|
if errClose := closer.Close(); errClose != nil {
|
|
t.Errorf("direct close: %v", errClose)
|
|
}
|
|
}()
|
|
wait.Wait()
|
|
|
|
if got := physical.closes.Load(); got != 1 {
|
|
t.Fatalf("physical websocket closes = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestWebsocketDrainDuringBindClosesOwnedConnectionOnce(t *testing.T) {
|
|
server, _ := newWebsocketTargetServer(t)
|
|
defer server.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
|
conn, physical := newCloseCountingWebsocketConn(t, wsURL)
|
|
closer := newWebsocketConnectionCloser(conn)
|
|
sess := &codexWebsocketSession{conn: conn, connCloser: closer, wsURL: wsURL, authID: "auth-a", readerConn: conn}
|
|
|
|
errBind := sess.bindExecutionLifecycle(cliproxyexecutor.Options{ExecutionLifecycle: drainDuringBindWebsocketLifecycle{}}, conn, closer, "model-a")
|
|
if errBind == nil {
|
|
t.Fatal("bind execution lifecycle error = nil, want drain error")
|
|
}
|
|
closeWebsocketAfterBindFailure(sess, conn, closer)
|
|
|
|
if got := physical.closes.Load(); got != 1 {
|
|
t.Fatalf("physical websocket closes = %d, want 1", got)
|
|
}
|
|
sess.connMu.Lock()
|
|
defer sess.connMu.Unlock()
|
|
if sess.conn != nil || sess.connCloser != nil || sess.lifecycle != nil {
|
|
t.Fatalf("drained session state = conn:%v closer:%v lifecycle:%v, want detached", sess.conn, sess.connCloser, sess.lifecycle)
|
|
}
|
|
}
|
|
|
|
func TestWebsocketTargetReplacementPhysicallyClosesOwnedConnectionOnce(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
}{
|
|
{name: "Codex"},
|
|
{name: "xAI"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
serverA, _ := newWebsocketTargetServer(t)
|
|
defer serverA.Close()
|
|
serverB, _ := newWebsocketTargetServer(t)
|
|
defer serverB.Close()
|
|
|
|
var ensure func(context.Context, *cliproxyauth.Auth, *codexWebsocketSession, string, string, http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error)
|
|
var closeSession func(string)
|
|
var sess *codexWebsocketSession
|
|
switch test.name {
|
|
case "Codex":
|
|
exec := NewCodexWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
ensure = exec.ensureUpstreamConn
|
|
closeSession = exec.CloseExecutionSession
|
|
sess = exec.getOrCreateSession("counted-target-change")
|
|
case "xAI":
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
ensure = exec.ensureUpstreamConn
|
|
closeSession = exec.CloseExecutionSession
|
|
sess = exec.getOrCreateSession("counted-target-change")
|
|
}
|
|
defer closeSession("counted-target-change")
|
|
|
|
wsURLA := "ws" + strings.TrimPrefix(serverA.URL, "http")
|
|
wsURLB := "ws" + strings.TrimPrefix(serverB.URL, "http")
|
|
connA, physical := newCloseCountingWebsocketConn(t, wsURLA)
|
|
sess.connMu.Lock()
|
|
sess.conn = connA
|
|
sess.connCloser = newWebsocketConnectionCloser(connA)
|
|
sess.wsURL = wsURLA
|
|
sess.authID = "auth-a"
|
|
sess.readerConn = connA
|
|
sess.connMu.Unlock()
|
|
lifecycle := &trackedWebsocketLifecycle{}
|
|
if errBind := sess.bindExecutionLifecycle(cliproxyexecutor.Options{ExecutionLifecycle: lifecycle}, connA, sess.connCloser, "model-a"); errBind != nil {
|
|
t.Fatalf("bind execution lifecycle: %v", errBind)
|
|
}
|
|
|
|
if _, _, _, errEnsure := ensure(context.Background(), &cliproxyauth.Auth{ID: "auth-b"}, sess, "auth-b", wsURLB, nil); errEnsure != nil {
|
|
t.Fatalf("replace websocket target: %v", errEnsure)
|
|
}
|
|
if got := physical.closes.Load(); got != 1 {
|
|
t.Fatalf("physical websocket closes = %d, want 1", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebsocketLifecycleEndThenInvalidateAndCloseAllPhysicallyClosesOnce(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func(*codexWebsocketSession, *websocket.Conn, *trackedWebsocketLifecycle)
|
|
}{
|
|
{
|
|
name: "Codex",
|
|
run: func(sess *codexWebsocketSession, conn *websocket.Conn, lifecycle *trackedWebsocketLifecycle) {
|
|
exec := NewCodexWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: map[string]*codexWebsocketSession{sess.sessionID: sess}}
|
|
lifecycle.End("lifecycle_ended")
|
|
exec.invalidateUpstreamConn(sess, conn, "invalidated", nil)
|
|
exec.CloseExecutionSession(cliproxyauth.CloseAllExecutionSessionsID)
|
|
},
|
|
},
|
|
{
|
|
name: "xAI",
|
|
run: func(sess *codexWebsocketSession, conn *websocket.Conn, lifecycle *trackedWebsocketLifecycle) {
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: map[string]*codexWebsocketSession{sess.sessionID: sess}}
|
|
lifecycle.End("lifecycle_ended")
|
|
exec.invalidateUpstreamConn(sess, conn, "invalidated", nil)
|
|
exec.CloseExecutionSession(cliproxyauth.CloseAllExecutionSessionsID)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server, _ := newWebsocketTargetServer(t)
|
|
defer server.Close()
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
|
conn, physical := newCloseCountingWebsocketConn(t, wsURL)
|
|
sess := &codexWebsocketSession{sessionID: "counted-lifecycle", conn: conn, connCloser: newWebsocketConnectionCloser(conn), wsURL: wsURL, authID: "auth-a", readerConn: conn}
|
|
lifecycle := &trackedWebsocketLifecycle{}
|
|
if errBind := sess.bindExecutionLifecycle(cliproxyexecutor.Options{ExecutionLifecycle: lifecycle}, conn, sess.connCloser, "model-a"); errBind != nil {
|
|
t.Fatalf("bind execution lifecycle: %v", errBind)
|
|
}
|
|
|
|
test.run(sess, conn, lifecycle)
|
|
if got := physical.closes.Load(); got != 1 {
|
|
t.Fatalf("physical websocket closes = %d, want 1", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebsocketExecutorsReconnectWhenSessionTargetChanges(t *testing.T) {
|
|
t.Run("Codex", func(t *testing.T) {
|
|
exec := NewCodexWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
testWebsocketExecutorReconnectsWhenSessionTargetChanges(
|
|
t,
|
|
exec.UpstreamDisconnectChan,
|
|
exec.getOrCreateSession,
|
|
exec.ensureUpstreamConn,
|
|
exec.CloseExecutionSession,
|
|
)
|
|
})
|
|
|
|
t.Run("xAI", func(t *testing.T) {
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
testWebsocketExecutorReconnectsWhenSessionTargetChanges(
|
|
t,
|
|
exec.UpstreamDisconnectChan,
|
|
exec.getOrCreateSession,
|
|
exec.ensureUpstreamConn,
|
|
exec.CloseExecutionSession,
|
|
)
|
|
})
|
|
}
|
|
|
|
func testWebsocketExecutorReconnectsWhenSessionTargetChanges(
|
|
t *testing.T,
|
|
disconnectChan func(string) <-chan error,
|
|
getSession func(string) *codexWebsocketSession,
|
|
ensureConn func(context.Context, *cliproxyauth.Auth, *codexWebsocketSession, string, string, http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error),
|
|
closeSession func(string),
|
|
) {
|
|
t.Helper()
|
|
|
|
serverA, closedA := newWebsocketTargetServer(t)
|
|
defer serverA.Close()
|
|
serverB, closedB := newWebsocketTargetServer(t)
|
|
defer serverB.Close()
|
|
|
|
sessionID := "target-switch-session"
|
|
disconnectCh := disconnectChan(sessionID)
|
|
sess := getSession(sessionID)
|
|
if sess == nil {
|
|
t.Fatal("expected websocket session")
|
|
}
|
|
defer closeSession(sessionID)
|
|
|
|
authA := &cliproxyauth.Auth{ID: "auth-a"}
|
|
authB := &cliproxyauth.Auth{ID: "auth-b"}
|
|
wsURLA := "ws" + strings.TrimPrefix(serverA.URL, "http")
|
|
wsURLB := "ws" + strings.TrimPrefix(serverB.URL, "http")
|
|
|
|
connA := ensureWebsocketTargetConn(t, ensureConn, authA, sess, authA.ID, wsURLA)
|
|
connAReused := ensureWebsocketTargetConn(t, ensureConn, authA, sess, authA.ID, wsURLA)
|
|
if connAReused != connA {
|
|
t.Fatal("matching websocket target did not reuse the existing connection")
|
|
}
|
|
|
|
connURLB := ensureWebsocketTargetConn(t, ensureConn, authA, sess, authA.ID, wsURLB)
|
|
if connURLB == connA {
|
|
t.Fatal("websocket URL change reused the existing connection")
|
|
}
|
|
if got := <-closedA; got != authA.ID {
|
|
t.Fatalf("closed server A auth = %q, want %q", got, authA.ID)
|
|
}
|
|
|
|
connAuthB := ensureWebsocketTargetConn(t, ensureConn, authB, sess, authB.ID, wsURLB)
|
|
if connAuthB == connURLB {
|
|
t.Fatal("websocket auth change reused the existing connection")
|
|
}
|
|
if got := <-closedB; got != authA.ID {
|
|
t.Fatalf("first closed server B auth = %q, want %q", got, authA.ID)
|
|
}
|
|
|
|
sess.connMu.Lock()
|
|
gotAuthID := sess.authID
|
|
gotURL := sess.wsURL
|
|
sess.connMu.Unlock()
|
|
if gotAuthID != authB.ID || gotURL != wsURLB {
|
|
t.Fatalf("session target = {%q %q}, want {%q %q}", gotAuthID, gotURL, authB.ID, wsURLB)
|
|
}
|
|
|
|
select {
|
|
case errDisconnect := <-disconnectCh:
|
|
t.Fatalf("controlled websocket target switch notified downstream: %v", errDisconnect)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func ensureWebsocketTargetConn(
|
|
t *testing.T,
|
|
ensureConn func(context.Context, *cliproxyauth.Auth, *codexWebsocketSession, string, string, http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error),
|
|
auth *cliproxyauth.Auth,
|
|
sess *codexWebsocketSession,
|
|
authID string,
|
|
wsURL string,
|
|
) *websocket.Conn {
|
|
t.Helper()
|
|
headers := http.Header{"X-Test-Auth": []string{authID}}
|
|
conn, _, resp, errEnsure := ensureConn(context.Background(), auth, sess, authID, wsURL, headers)
|
|
if resp != nil && resp.Body != nil {
|
|
defer func() {
|
|
if errClose := resp.Body.Close(); errClose != nil {
|
|
t.Errorf("close handshake response body: %v", errClose)
|
|
}
|
|
}()
|
|
}
|
|
if errEnsure != nil {
|
|
t.Fatalf("ensure websocket connection: %v", errEnsure)
|
|
}
|
|
if conn == nil {
|
|
t.Fatal("ensure websocket connection returned nil")
|
|
}
|
|
return conn
|
|
}
|
|
|
|
func newWebsocketTargetServer(t *testing.T) (*httptest.Server, <-chan string) {
|
|
t.Helper()
|
|
closed := make(chan string, 4)
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
authID := r.Header.Get("X-Test-Auth")
|
|
conn, errUpgrade := upgrader.Upgrade(w, r, nil)
|
|
if errUpgrade != nil {
|
|
t.Errorf("upgrade websocket: %v", errUpgrade)
|
|
return
|
|
}
|
|
defer func() {
|
|
if errClose := conn.Close(); errClose != nil {
|
|
t.Errorf("close upstream websocket: %v", errClose)
|
|
}
|
|
closed <- authID
|
|
}()
|
|
for {
|
|
if _, _, errRead := conn.ReadMessage(); errRead != nil {
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
return server, closed
|
|
}
|
|
|
|
type registryDrainWebsocketLifecycle struct {
|
|
scope *executionregistry.Scope
|
|
ends atomic.Int32
|
|
}
|
|
|
|
func (l *registryDrainWebsocketLifecycle) Bind(closeFn func() error) error {
|
|
return l.scope.Bind(closeFn)
|
|
}
|
|
|
|
func (l *registryDrainWebsocketLifecycle) End(string) {
|
|
l.ends.Add(1)
|
|
l.scope.End("websocket_closed")
|
|
}
|
|
|
|
func (l *registryDrainWebsocketLifecycle) Retain() {}
|
|
|
|
type websocketHomeDispatcher struct {
|
|
provider string
|
|
}
|
|
|
|
func (d websocketHomeDispatcher) HeartbeatOK() bool { return true }
|
|
|
|
func (d websocketHomeDispatcher) RPopAuth(context.Context, string, string, http.Header, int) ([]byte, error) {
|
|
return json.Marshal(map[string]any{"auth": map[string]any{
|
|
"id": "home-websocket-auth",
|
|
"provider": d.provider,
|
|
"status": "active",
|
|
"attributes": map[string]string{
|
|
"api_key": "home-key",
|
|
},
|
|
}})
|
|
}
|
|
|
|
func (websocketHomeDispatcher) AbortAmbiguousDispatch() {}
|
|
|
|
type accountedWebsocketHomeDispatcher struct {
|
|
provider string
|
|
baseURL string
|
|
calls atomic.Int32
|
|
releases atomic.Int32
|
|
before atomic.Bool
|
|
}
|
|
|
|
func (*accountedWebsocketHomeDispatcher) HeartbeatOK() bool { return true }
|
|
|
|
func (d *accountedWebsocketHomeDispatcher) RPopAuth(_ context.Context, model string, _ string, _ http.Header, _ int) ([]byte, error) {
|
|
call := d.calls.Add(1)
|
|
if call > 1 && d.releases.Load() != call-1 {
|
|
d.before.Store(false)
|
|
} else if call > 1 {
|
|
d.before.Store(true)
|
|
}
|
|
upstreamModel := "model-a"
|
|
if strings.Contains(strings.ToLower(model), "(custom)") {
|
|
upstreamModel = "model-a(custom)"
|
|
}
|
|
return json.Marshal(map[string]any{
|
|
"model": upstreamModel,
|
|
"auth_index": "accounted-websocket-auth",
|
|
"auth": map[string]any{
|
|
"id": "accounted-websocket-auth",
|
|
"provider": d.provider,
|
|
"status": "active",
|
|
"attributes": map[string]string{
|
|
"api_key": "test-key",
|
|
"base_url": d.baseURL,
|
|
"websockets": "true",
|
|
},
|
|
},
|
|
"concurrency": map[string]any{
|
|
"accounted": true,
|
|
"credential_id": "accounted-websocket-auth",
|
|
"model": upstreamModel,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (*accountedWebsocketHomeDispatcher) AbortAmbiguousDispatch() {}
|
|
|
|
func TestAuditAccountedCodexXAIReconnectReuseAndTargetChange(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
newExecutor func() cliproxyauth.ProviderExecutor
|
|
}{
|
|
{
|
|
name: "Codex",
|
|
provider: "codex",
|
|
newExecutor: func() cliproxyauth.ProviderExecutor {
|
|
executor := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
|
|
executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
return executor
|
|
},
|
|
},
|
|
{
|
|
name: "xAI",
|
|
provider: "xai",
|
|
newExecutor: func() cliproxyauth.ProviderExecutor {
|
|
executor := NewXAIWebsocketsExecutor(&config.Config{})
|
|
executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
executor.idStore = &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)}
|
|
return executor
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
var connections atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, errUpgrade := upgrader.Upgrade(w, r, nil)
|
|
if errUpgrade != nil {
|
|
t.Errorf("upgrade websocket: %v", errUpgrade)
|
|
return
|
|
}
|
|
connections.Add(1)
|
|
defer func() { _ = conn.Close() }()
|
|
for {
|
|
if _, _, errRead := conn.ReadMessage(); errRead != nil {
|
|
return
|
|
}
|
|
completed := []byte(`{"type":"response.completed","response":{"id":"response-1","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
registry := executionregistry.New()
|
|
dispatcher := &accountedWebsocketHomeDispatcher{provider: test.provider, baseURL: server.URL}
|
|
var releaseGroups []executionregistry.ReleaseGroup
|
|
registry.SetReleaseSink(func(group executionregistry.ReleaseGroup, _ int64) {
|
|
dispatcher.releases.Add(1)
|
|
releaseGroups = append(releaseGroups, group)
|
|
})
|
|
manager := cliproxyauth.NewManager(nil, nil, nil)
|
|
manager.SetConfig(&config.Config{Home: config.HomeConfig{Enabled: true}})
|
|
manager.PublishHomeDispatch(dispatcher, registry, 1)
|
|
manager.RegisterExecutor(test.newExecutor())
|
|
t.Cleanup(func() { manager.CloseExecutionSession("accounted-websocket-session") })
|
|
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
opts := cliproxyexecutor.Options{
|
|
Stream: true,
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
Metadata: map[string]any{
|
|
cliproxyexecutor.ExecutionSessionMetadataKey: "accounted-websocket-session",
|
|
cliproxyexecutor.PinnedAuthMetadataKey: "accounted-websocket-auth",
|
|
},
|
|
}
|
|
execute := func(model string) {
|
|
t.Helper()
|
|
result, errExecute := manager.ExecuteStream(ctx, []string{test.provider}, cliproxyexecutor.Request{Model: model, Payload: []byte(`{"model":"model-a","input":[]}`)}, opts)
|
|
if errExecute != nil {
|
|
t.Fatalf("ExecuteStream(%q) error = %v", model, errExecute)
|
|
}
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("ExecuteStream(%q) chunk error = %v", model, chunk.Err)
|
|
}
|
|
}
|
|
}
|
|
|
|
execute(" MODEL-A(HIGH) ")
|
|
execute("model-a")
|
|
if got := dispatcher.calls.Load(); got != 1 {
|
|
t.Fatalf("Home RPOP calls = %d, want 1 for canonical retained reuse", got)
|
|
}
|
|
manager.CloseExecutionSession("accounted-websocket-session")
|
|
execute("model-a")
|
|
execute("model-a(custom)")
|
|
if got := dispatcher.calls.Load(); got != 3 {
|
|
t.Fatalf("Home RPOP calls = %d, want 3 after reconnect and target change", got)
|
|
}
|
|
if !dispatcher.before.Load() {
|
|
t.Fatal("previous accounted selection was not released before redispatch")
|
|
}
|
|
manager.CloseExecutionSession("accounted-websocket-session")
|
|
wantGroups := []executionregistry.ReleaseGroup{
|
|
{CredentialID: "accounted-websocket-auth", Model: "model-a"},
|
|
{CredentialID: "accounted-websocket-auth", Model: "model-a"},
|
|
{CredentialID: "accounted-websocket-auth", Model: "model-a(custom)"},
|
|
}
|
|
if !reflect.DeepEqual(releaseGroups, wantGroups) {
|
|
t.Fatalf("release groups = %#v, want %#v", releaseGroups, wantGroups)
|
|
}
|
|
if got := connections.Load(); got != 3 {
|
|
t.Fatalf("upstream websocket connections = %d, want 3", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHomeSelectionRegistryDrainClosesRealWebsocketSessions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
newExecutor func() (cliproxyauth.ProviderExecutor, func(string) *codexWebsocketSession, func(context.Context, *cliproxyauth.Auth, *codexWebsocketSession, string, string, http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error))
|
|
}{
|
|
{
|
|
name: "Codex",
|
|
provider: "codex",
|
|
newExecutor: func() (cliproxyauth.ProviderExecutor, func(string) *codexWebsocketSession, func(context.Context, *cliproxyauth.Auth, *codexWebsocketSession, string, string, http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error)) {
|
|
executor := NewCodexWebsocketsExecutor(&config.Config{})
|
|
executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
return executor, executor.getOrCreateSession, executor.ensureUpstreamConn
|
|
},
|
|
},
|
|
{
|
|
name: "xAI",
|
|
provider: "xai",
|
|
newExecutor: func() (cliproxyauth.ProviderExecutor, func(string) *codexWebsocketSession, func(context.Context, *cliproxyauth.Auth, *codexWebsocketSession, string, string, http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error)) {
|
|
executor := NewXAIWebsocketsExecutor(&config.Config{})
|
|
executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
return executor, executor.getOrCreateSession, executor.ensureUpstreamConn
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server, closed := newWebsocketTargetServer(t)
|
|
defer server.Close()
|
|
|
|
executor, getSession, ensureConn := test.newExecutor()
|
|
registry := executionregistry.New()
|
|
manager := cliproxyauth.NewManager(nil, nil, nil)
|
|
manager.SetConfig(&config.Config{Home: config.HomeConfig{Enabled: true}})
|
|
manager.PublishHomeDispatch(websocketHomeDispatcher{provider: test.provider}, registry, 1)
|
|
manager.RegisterExecutor(executor)
|
|
selection, errSelect := manager.SelectHomeAuthByKind(context.Background(), test.provider, "model-a", cliproxyauth.AuthKindAPIKey, cliproxyexecutor.Options{})
|
|
if errSelect != nil {
|
|
t.Fatalf("SelectHomeAuthByKind() error = %v", errSelect)
|
|
}
|
|
auth := selection.CloneAuth()
|
|
sess := getSession("real-home-drain")
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
|
conn := ensureWebsocketTargetConn(t, ensureConn, auth, sess, auth.ID, wsURL)
|
|
if errBind := sess.bindExecutionLifecycle(cliproxyexecutor.Options{ExecutionLifecycle: selection}, conn, sess.connCloser, "model-a"); errBind != nil {
|
|
t.Fatalf("bind execution lifecycle: %v", errBind)
|
|
}
|
|
|
|
drainCtx, cancelDrain := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancelDrain()
|
|
if errDrain := registry.Drain(drainCtx); errDrain != nil {
|
|
t.Fatalf("Drain() error = %v", errDrain)
|
|
}
|
|
if selection.Active() {
|
|
t.Fatal("registry drain did not end the Home dispatch selection")
|
|
}
|
|
if got := <-closed; got != auth.ID {
|
|
t.Fatalf("closed server auth = %q, want %q", got, auth.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type codex426RetryDispatcher struct {
|
|
calls atomic.Int32
|
|
baseURLs []string
|
|
websockets []bool
|
|
releases atomic.Int32
|
|
releasedBeforeSecondRPop atomic.Bool
|
|
}
|
|
|
|
func (d *codex426RetryDispatcher) HeartbeatOK() bool { return true }
|
|
|
|
func (d *codex426RetryDispatcher) RPopAuth(_ context.Context, model string, _ string, _ http.Header, _ int) ([]byte, error) {
|
|
call := int(d.calls.Add(1))
|
|
if call > len(d.baseURLs) {
|
|
return nil, fmt.Errorf("unexpected Home dispatch %d", call)
|
|
}
|
|
if call == 2 {
|
|
d.releasedBeforeSecondRPop.Store(d.releases.Load() == 1)
|
|
}
|
|
credentialID := "codex-home-" + strconv.Itoa(call)
|
|
attributes := map[string]string{
|
|
"api_key": "home-key",
|
|
"base_url": d.baseURLs[call-1],
|
|
}
|
|
if call <= len(d.websockets) && d.websockets[call-1] {
|
|
attributes["websockets"] = "true"
|
|
}
|
|
return json.Marshal(map[string]any{
|
|
"model": model,
|
|
"auth_index": credentialID,
|
|
"auth": map[string]any{
|
|
"id": credentialID,
|
|
"provider": "codex",
|
|
"status": "active",
|
|
"attributes": attributes,
|
|
},
|
|
"concurrency": map[string]any{
|
|
"accounted": true,
|
|
"credential_id": credentialID,
|
|
"model": model,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (*codex426RetryDispatcher) AbortAmbiguousDispatch() {}
|
|
|
|
func TestAuditHomeCodex426WebsocketToHTTPFreshSelection(t *testing.T) {
|
|
upgradeRequired := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "websocket upgrade required", http.StatusUpgradeRequired)
|
|
}))
|
|
defer upgradeRequired.Close()
|
|
|
|
var httpFallbackCalls atomic.Int32
|
|
httpFallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/responses" {
|
|
http.Error(w, "unexpected fallback request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
httpFallbackCalls.Add(1)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"response-1\",\"output\":[],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n"))
|
|
}))
|
|
defer httpFallback.Close()
|
|
|
|
executor := NewCodexAutoExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
|
|
executor.wsExec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
dispatcher := &codex426RetryDispatcher{
|
|
baseURLs: []string{upgradeRequired.URL, httpFallback.URL},
|
|
websockets: []bool{true, false},
|
|
}
|
|
registry := executionregistry.New()
|
|
var releaseGroups []executionregistry.ReleaseGroup
|
|
var releaseGroupsMu sync.Mutex
|
|
releaseFlusher := internalhome.NewReleaseFlusher(func() config.CredentialConcurrencyConfig {
|
|
return config.CredentialConcurrencyConfig{
|
|
ReleaseFlushInterval: time.Millisecond,
|
|
ReleaseMaxBackoff: 10 * time.Millisecond,
|
|
}
|
|
}, func(_ context.Context, frame internalhome.ConcurrencyReleaseFrame) error {
|
|
dispatcher.releases.Add(1)
|
|
releaseGroupsMu.Lock()
|
|
releaseGroups = append(releaseGroups, executionregistry.ReleaseGroup{CredentialID: frame.CredentialID, Model: frame.Model})
|
|
releaseGroupsMu.Unlock()
|
|
return nil
|
|
})
|
|
registry.SetReleaseSink(releaseFlusher.MarkDirty)
|
|
releaseCtx, cancelRelease := context.WithCancel(context.Background())
|
|
releaseDone := make(chan struct{})
|
|
go func() {
|
|
defer close(releaseDone)
|
|
releaseFlusher.Run(releaseCtx)
|
|
}()
|
|
defer func() {
|
|
cancelRelease()
|
|
<-releaseDone
|
|
}()
|
|
manager := cliproxyauth.NewManager(nil, nil, nil)
|
|
manager.SetConfig(&config.Config{Home: config.HomeConfig{Enabled: true}})
|
|
manager.PublishHomeDispatch(dispatcher, registry, 1)
|
|
manager.RegisterExecutor(executor)
|
|
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
result, errExecute := manager.ExecuteStream(ctx, []string{"codex"}, cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"model":"gpt-5-codex","input":[{"type":"message","role":"user","content":"hello"}]}`)}, cliproxyexecutor.Options{Stream: true, SourceFormat: sdktranslator.FormatOpenAIResponse, ResponseFormat: sdktranslator.FormatOpenAIResponse, Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "home-426"}})
|
|
if errExecute != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", errExecute)
|
|
}
|
|
if !dispatcher.releasedBeforeSecondRPop.Load() {
|
|
t.Fatal("first accounted selection was not released before the 426 retry RPOP")
|
|
}
|
|
if got := dispatcher.releases.Load(); got != 1 {
|
|
t.Fatalf("accounted releases before response completion = %d, want 1", got)
|
|
}
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
}
|
|
if got := dispatcher.calls.Load(); got != 2 {
|
|
t.Fatalf("Home RPOP calls = %d, want 2 after 426", got)
|
|
}
|
|
if got := httpFallbackCalls.Load(); got != 1 {
|
|
t.Fatalf("HTTP fallback calls = %d, want 1 on the fresh Home selection", got)
|
|
}
|
|
deadline := time.NewTimer(time.Second)
|
|
defer deadline.Stop()
|
|
for dispatcher.releases.Load() != 2 {
|
|
select {
|
|
case <-deadline.C:
|
|
t.Fatalf("accounted releases after response completion = %d, want 2", dispatcher.releases.Load())
|
|
case <-time.After(time.Millisecond):
|
|
}
|
|
}
|
|
releaseGroupsMu.Lock()
|
|
gotReleaseGroups := append([]executionregistry.ReleaseGroup(nil), releaseGroups...)
|
|
releaseGroupsMu.Unlock()
|
|
wantReleaseGroups := []executionregistry.ReleaseGroup{
|
|
{CredentialID: "codex-home-1", Model: "gpt-5-codex"},
|
|
{CredentialID: "codex-home-2", Model: "gpt-5-codex"},
|
|
}
|
|
if !reflect.DeepEqual(gotReleaseGroups, wantReleaseGroups) {
|
|
t.Fatalf("accounted release groups = %#v, want %#v", gotReleaseGroups, wantReleaseGroups)
|
|
}
|
|
}
|
|
|
|
func TestWebsocketRegistryDrainClosesAndEndsRetainedSession(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
getSession func(string) *codexWebsocketSession
|
|
ensureConn func(context.Context, *cliproxyauth.Auth, *codexWebsocketSession, string, string, http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error)
|
|
}{
|
|
{
|
|
name: "Codex",
|
|
getSession: func(sessionID string) *codexWebsocketSession {
|
|
executor := NewCodexWebsocketsExecutor(&config.Config{})
|
|
executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
return executor.getOrCreateSession(sessionID)
|
|
},
|
|
ensureConn: func(ctx context.Context, auth *cliproxyauth.Auth, sess *codexWebsocketSession, authID, wsURL string, headers http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error) {
|
|
executor := NewCodexWebsocketsExecutor(&config.Config{})
|
|
return executor.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, headers)
|
|
},
|
|
},
|
|
{
|
|
name: "xAI shared session",
|
|
getSession: func(sessionID string) *codexWebsocketSession {
|
|
executor := NewXAIWebsocketsExecutor(&config.Config{})
|
|
executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
return executor.getOrCreateSession(sessionID)
|
|
},
|
|
ensureConn: func(ctx context.Context, auth *cliproxyauth.Auth, sess *codexWebsocketSession, authID, wsURL string, headers http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error) {
|
|
executor := NewXAIWebsocketsExecutor(&config.Config{})
|
|
return executor.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, headers)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server, closed := newWebsocketTargetServer(t)
|
|
defer server.Close()
|
|
|
|
registry := executionregistry.New()
|
|
pending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatalf("BeginDispatch() error = %v", errBegin)
|
|
}
|
|
scope, errInstall := registry.Install(pending, executionregistry.ScopeSpec{Kind: "websocket"})
|
|
if errInstall != nil {
|
|
t.Fatalf("Install() error = %v", errInstall)
|
|
}
|
|
lifecycle := ®istryDrainWebsocketLifecycle{scope: scope}
|
|
auth := &cliproxyauth.Auth{ID: "auth-a"}
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
|
sess := test.getSession("drain-retained-session")
|
|
conn := ensureWebsocketTargetConn(t, test.ensureConn, auth, sess, auth.ID, wsURL)
|
|
if errBind := sess.bindExecutionLifecycle(cliproxyexecutor.Options{ExecutionLifecycle: lifecycle}, conn, sess.connCloser, "model-a"); errBind != nil {
|
|
t.Fatalf("bind execution lifecycle: %v", errBind)
|
|
}
|
|
|
|
drainCtx, cancelDrain := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancelDrain()
|
|
if errDrain := registry.Drain(drainCtx); errDrain != nil {
|
|
t.Fatalf("Drain() error = %v", errDrain)
|
|
}
|
|
if got := lifecycle.ends.Load(); got != 1 {
|
|
t.Fatalf("lifecycle End calls = %d, want 1", got)
|
|
}
|
|
if got := <-closed; got != auth.ID {
|
|
t.Fatalf("closed server auth = %q, want %q", got, auth.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|