Files
tbphp 0a19e80d26 fix(gateway): 支持已绑定 WebSocket 通知客户端重连 (#652)
* fix(gateway): 支持已绑定 WebSocket 通知重连

* fix(gateway): 修复 WebSocket 冻结与收尾竞态

* fix(gateway): 保留超限请求登记直到错误交付完成

* fix(gateway): 修正读取失败时的关闭与注销顺序

* fix(gateway): 将二进制消息纳入关闭前登记
2026-09-14 16:32:37 +08:00

1125 lines
40 KiB
Go

package gateway
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gpt-load/internal/accessquota"
"gpt-load/internal/channel"
"gpt-load/internal/dialect"
"gpt-load/internal/execution"
"gpt-load/internal/health"
"gpt-load/internal/httplifecycle"
"gpt-load/internal/parameteroverride"
"gpt-load/internal/pricing"
"gpt-load/internal/scheduler"
"gpt-load/internal/state"
"gpt-load/internal/telemetry"
"gpt-load/internal/testutil/encryptiontest"
)
func websocketTestHandler(t *testing.T, endpoint string, id channel.ID) (*Handler, *gin.Engine, state.CompileInput) {
t.Helper()
service := encryptiontest.Service(t, "websocket-fixture-key")
input := state.CompileInput{ChannelRegistry: channel.NewRegistry(),
Groups: []state.GroupConfig{{ID: 1, Name: "ws", ConnectionType: "api_key", ChannelID: id, Params: json.RawMessage(fmt.Sprintf(`{"base_url":%q}`, endpoint)), Models: []state.ModelConfig{{ID: "upstream", Alias: "public"}}, Enabled: true}},
Credentials: []state.CredentialConfig{testCredentialConfig(1, 1)},
AccessKeys: []state.AccessKeyConfig{{ID: 1, Name: "client", KeyHash: service.Hash("gl-client"), Status: state.AccessKeyStatusActive}},
}
manager := state.NewManager()
if _, err := manager.Publish(input); err != nil {
t.Fatal(err)
}
registry := state.NewCredentialRegistry()
if err := registry.ReplaceCredentials([]state.CredentialEntry{testCredentialEntry(t, service, 1, 1, "upstream-key")}); err != nil {
t.Fatal(err)
}
h := NewHandler(manager, registry, service, newTestExecutionForwarder(t), dialect.NewSet(dialect.NewOpenAIResponses()), health.NewStatsStore(), health.NewMutationCoordinator(), nil, nil, nil)
gin.SetMode(gin.TestMode)
engine := gin.New()
bindGatewayRoutesForTest(t, engine, h)
return h, engine, input
}
func websocketCompleted(id, lane string) []byte {
label := ""
if lane != "" {
label = fmt.Sprintf(`,"stream_id":%q`, lane)
}
return []byte(fmt.Sprintf(`{"type":"response.completed"%s,"response":{"id":%q,"object":"response","status":"completed","model":"upstream","usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`, label, id))
}
func waitWebsocketLogs(t *testing.T, sink *recordingRequestLogSink, count int) []telemetry.RequestEvent {
t.Helper()
deadline := time.After(2 * time.Second)
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for {
events := sink.snapshot()
if len(events) >= count {
return events
}
select {
case <-deadline:
t.Fatalf("logs=%d want %d", len(events), count)
case <-ticker.C:
}
}
}
func TestWebsocketIdleUpstreamDisconnectClosesClient(t *testing.T) {
closeUpstream := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if _, _, err = conn.ReadMessage(); err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, websocketCompleted("resp_1", ""))
<-closeUpstream
}))
defer upstream.Close()
h, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
sink := &recordingRequestLogSink{}
h.requestLogSink = sink
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"hello"}`))
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatal(err)
}
waitWebsocketLogs(t, sink, 1)
close(closeUpstream)
_ = conn.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
_, _, err := conn.ReadMessage()
if err == nil {
t.Fatal("downstream remained open")
}
if timeout, ok := err.(interface{ Timeout() bool }); ok && timeout.Timeout() {
t.Fatal("idle upstream disconnect did not close downstream")
}
}
func TestWebsocketQuotaCountsConcurrentTurnsOnceWithoutReservation(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
var lanes []string
for len(lanes) < 2 {
var req struct {
StreamID string `json:"stream_id"`
}
if conn.ReadJSON(&req) != nil {
return
}
lanes = append(lanes, req.StreamID)
}
for _, lane := range lanes {
if conn.WriteMessage(websocket.TextMessage, websocketCompleted("resp_"+lane, lane)) != nil {
return
}
}
_, _, _ = conn.ReadMessage()
}))
defer upstream.Close()
h, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
sink := &recordingRequestLogSink{}
h.requestLogSink = sink
limiter := &recordingAccessKeyRPMLimiter{}
h.limiter = limiter
runtime := accessquota.NewRuntime()
h.accessQuota = runtime
if err := runtime.Reconcile(map[uint][]accessquota.Rule{1: {{ID: 1, Revision: 1, Kind: accessquota.KindTotal, LimitNanoUSD: 4}}}); err != nil {
t.Fatal(err)
}
table, err := pricing.NewTable([]pricing.Rule{{Identity: pricing.Identity{ChannelID: "openai", ModelID: "upstream"}, Prices: pricing.Prices{Input: pricing.Price{NanoUSDPerMillion: 1_000_000, Set: true}, Output: pricing.Price{NanoUSDPerMillion: 1_000_000, Set: true}}}})
if err != nil {
t.Fatal(err)
}
h.priceTables = &mutableGatewayPriceTableProvider{table: table}
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
for _, lane := range []string{"a", "b"} {
_ = conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"type":"response.create","stream_id":%q,"model":"public","input":"hello"}`, lane)))
}
for i := 0; i < 2; i++ {
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatal(err)
}
}
events := waitWebsocketLogs(t, sink, 2)
for _, event := range events {
if event.Status != telemetry.RequestStatusSuccess || event.Usage.Pricing.EstimatedCostNanoUSD != 3 || len(event.Attempts) != 1 {
t.Fatalf("wrong per-turn record: %+v", event)
}
}
if got := runtime.Snapshot(1, time.Now()).Rules[0].UsedNanoUSD; got != 6 {
t.Fatalf("used=%d want 6 (existing in-flight overspend)", got)
}
if len(limiter.snapshot()) != 2 {
t.Fatal("RPM counted handshake or missed a turn")
}
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","stream_id":"a","model":"public","input":"over quota"}`))
var rejected struct {
Error struct {
Type string `json:"type"`
Code string `json:"code"`
} `json:"error"`
}
if err := conn.ReadJSON(&rejected); err != nil || rejected.Error.Code != "access_key_cost_limit_exceeded" || rejected.Error.Type != "usage_limit_reached" {
t.Fatalf("quota rejection=%+v err=%v", rejected, err)
}
}
func TestWebsocketHandlerRemainsTrackedUntilShutdown(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
if _, _, err = conn.ReadMessage(); err != nil {
return
}
}
}))
defer upstream.Close()
h, _, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
h.lifecycle = httplifecycle.NewCoordinator()
engine := gin.New()
bindGatewayRoutesForTest(t, engine, h)
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
if h.lifecycle.Wait(ctx) == nil {
t.Fatal("WS handler returned before socket closed")
}
h.lifecycle.BeginShutdown()
ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second)
defer cancel2()
if err := h.lifecycle.Wait(ctx2); err != nil {
t.Fatal(err)
}
if _, _, err := conn.ReadMessage(); err == nil {
t.Fatal("shutdown left WS socket open")
}
h.websocketBudget.mu.Lock()
defer h.websocketBudget.mu.Unlock()
if h.websocketBudget.connections != 0 || h.websocketBudget.input != 0 {
t.Fatal("shutdown leaked connection/input budget")
}
}
func TestWebsocketStoredAndTemporaryOwnershipRemainSeparate(t *testing.T) {
var turns, connections atomic.Int32
requests := make(chan map[string]any, 8)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
connections.Add(1)
for {
var req map[string]any
if conn.ReadJSON(&req) != nil {
return
}
requests <- req
n := turns.Add(1)
if conn.WriteMessage(websocket.TextMessage, websocketCompleted(fmt.Sprintf("resp_%d", n), "")) != nil {
return
}
}
}))
defer upstream.Close()
h, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
sink := &recordingRequestLogSink{}
h.requestLogSink = sink
server := httptest.NewServer(engine)
defer server.Close()
first := dialGatewayWebsocket(t, server.URL)
// 省略 store 沿用上游默认;真实预热不能由网关合成响应。
_ = first.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"warm","generate":false}`))
if _, _, err := first.ReadMessage(); err != nil {
t.Fatal(err)
}
if req := <-requests; req["generate"] != false {
t.Fatal("prewarm flag lost")
}
waitWebsocketLogs(t, sink, 1)
if _, ok := h.responseBindings.Lookup(1, "resp_1"); !ok {
t.Fatal("omitted store did not use native storage contract")
}
_ = first.Close()
second := dialGatewayWebsocket(t, server.URL)
_ = second.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"continue","previous_response_id":"resp_1","store":false}`))
if _, _, err := second.ReadMessage(); err != nil {
t.Fatal(err)
}
if req := <-requests; req["previous_response_id"] != "resp_1" || req["store"] != false {
t.Fatal("persistent parent or child store changed")
}
waitWebsocketLogs(t, sink, 2)
if _, ok := h.responseBindings.Lookup(1, "resp_2"); ok {
t.Fatal("store:false escaped into persistent index")
}
_ = second.Close()
third := dialGatewayWebsocket(t, server.URL)
_ = third.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","previous_response_id":"resp_2","input":"missing"}`))
var rejection struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := third.ReadJSON(&rejection); err != nil || rejection.Error.Code != "response_binding_not_found" {
t.Fatalf("temporary restore=%+v err=%v", rejection, err)
}
if turns.Load() != 2 || connections.Load() != 2 {
t.Fatal("missing parent was scanned or resent upstream")
}
}
func TestWebsocketBoundSerialTargetRejectsNamedFlow(t *testing.T) {
var turns atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
if _, _, err = conn.ReadMessage(); err != nil {
return
}
turns.Add(1)
if conn.WriteMessage(websocket.TextMessage, websocketCompleted("resp_1", "")) != nil {
return
}
}
}))
defer upstream.Close()
_, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.XAI)
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"hello"}`))
if _, body, err := conn.ReadMessage(); err != nil {
t.Fatal(err)
} else if !strings.Contains(string(body), `"type":"response.completed"`) {
t.Fatalf("first turn: %s", body)
}
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","stream_id":"child","model":"public","input":"hello"}`))
var event struct {
StreamID string `json:"stream_id"`
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := conn.ReadJSON(&event); err != nil || event.StreamID != "child" || event.Error.Code != "websocket_capability_unavailable" {
t.Fatalf("named flow=%+v err=%v", event, err)
}
if turns.Load() != 1 {
t.Fatal("named flow was stripped or executed on serial upstream")
}
}
func TestWebsocketUpgradeValidationAndHTTPBoundary(t *testing.T) {
var forwarded atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
forwarded.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer upstream.Close()
_, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
for _, test := range []struct {
name, auth, key, upgrade string
status int
}{
{"auth first", "bad", "", "websocket", 401},
{"missing key", "gl-client", "", "websocket", 400},
{"invalid key", "gl-client", "short", "websocket", 400},
{"partial intent", "gl-client", "dGhlIHNhbXBsZSBub25jZQ==", "", 400},
{"ordinary GET", "gl-client", "", "", 400},
} {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
request.Header.Set("Authorization", "Bearer "+test.auth)
if test.upgrade != "" {
request.Header.Set("Upgrade", test.upgrade)
request.Header.Set("Connection", "keep-alive, Upgrade")
request.Header.Set("Sec-WebSocket-Version", "13")
}
if test.key != "" {
request.Header.Set("Sec-WebSocket-Key", test.key)
}
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != test.status {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
})
}
if forwarded.Load() != 0 {
t.Fatal("malformed handshakes reached HTTP upstream")
}
}
func TestWebsocketTerminalEvidence(t *testing.T) {
for _, test := range []struct {
name, payload string
success bool
}{
{"completed done", `{"type":"response.done","response":{"id":"resp_1","object":"response","status":"completed","usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`, true},
{"failed done", `{"type":"response.done","response":{"id":"resp_1","object":"response","status":"failed","error":{"code":"failed"}}}`, false},
{"missing ID", `{"type":"response.completed","response":{"object":"response","status":"completed"}}`, false},
{"contradictory status", `{"type":"response.completed","response":{"id":"resp_1","object":"response","status":"failed"}}`, false},
} {
t.Run(test.name, func(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if _, _, err = conn.ReadMessage(); err == nil {
_ = conn.WriteMessage(websocket.TextMessage, []byte(test.payload))
}
_, _, _ = conn.ReadMessage()
}))
defer upstream.Close()
h, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
sink := &recordingRequestLogSink{}
h.requestLogSink = sink
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"hello"}`))
_, _, _ = conn.ReadMessage()
events := waitWebsocketLogs(t, sink, 1)
if (events[0].Status == telemetry.RequestStatusSuccess) != test.success {
t.Fatalf("terminal status=%s expected success=%t", events[0].Status, test.success)
}
})
}
}
func TestWebsocketPreparationPreservesExplicitControls(t *testing.T) {
body := []byte(`{"type":"response.create","model":"public","input":"warm","stream":true,"generate":false,"store":true,"previous_response_id":"resp_parent"}`)
original, err := inspectWebsocketRequest(body)
if err != nil {
t.Fatal(err)
}
upstreamModel := "upstream"
for _, test := range []struct {
name, field string
value any
reject bool
}{
{"plain", "", nil, false}, {"store downgrade", "store", false, true}, {"fake prewarm", "generate", true, true}, {"parent replacement", "previous_response_id", "resp_other", true}, {"new stream", "stream_id", "child", true},
} {
t.Run(test.name, func(t *testing.T) {
selection := scheduler.Selection{UpstreamModelID: &upstreamModel}
if test.field != "" {
rules, err := parameteroverride.Compile([]any{map[string]any{"match": map[string]any{"protocol": "openai-responses", "model": "public"}, "set": map[string]any{test.field: test.value}}})
if err != nil {
if test.field == "store" && test.reject {
return
}
t.Fatal(err)
}
selection.Group.ParameterOverrides = rules
}
payload, _, err := prepareWebsocketPayload(body, original, selection)
if (err != nil) != test.reject {
t.Fatalf("reject=%t err=%v payload=%s", test.reject, err, payload)
}
if !test.reject {
var req map[string]any
_ = json.Unmarshal(payload, &req)
if req["type"] != nil || req["stream"] != nil || req["model"] != "upstream" || req["generate"] != false || req["previous_response_id"] != "resp_parent" {
t.Fatalf("session create-body contract violated: %s", payload)
}
}
})
}
}
func TestWebsocketStreamFieldValidation(t *testing.T) {
for _, test := range []struct {
name, field string
valid bool
}{
{"absent", "", true},
{"true", `,"stream":true`, true},
{"false", `,"stream":false`, true},
{"null", `,"stream":null`, false},
{"string", `,"stream":"true"`, false},
{"number", `,"stream":1`, false},
{"duplicate", `,"stream":true,"stream":false`, false},
{"case collision", `,"stream":true,"Stream":false`, false},
} {
t.Run(test.name, func(t *testing.T) {
request, err := inspectWebsocketRequest([]byte(`{"type":"response.create","model":"public","input":[]` + test.field + `}`))
if (err == nil) != test.valid {
t.Fatalf("valid=%t err=%v", test.valid, err)
}
if err == nil && !request.metadata.Stream {
t.Fatal("WS request stopped being an event stream")
}
})
}
}
func TestWebsocketStreamFieldKeepsNativeEventsAcrossChannels(t *testing.T) {
for _, id := range []channel.ID{channel.OpenAI, channel.XAI, channel.CLIProxyAPI, channel.Sub2API, channel.GPTLoad} {
t.Run(string(id), func(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for index := 0; index < 3; index++ {
var request map[string]any
if conn.ReadJSON(&request) != nil {
return
}
if _, exists := request["stream"]; exists {
t.Error("redundant stream field reached upstream WS")
}
responseID := fmt.Sprintf("resp_%d", index)
if index == 0 {
if request["generate"] != false {
t.Error("prewarm lost generate:false")
}
} else {
if request["previous_response_id"] != fmt.Sprintf("resp_%d", index-1) {
t.Error("continuation lost parent response")
}
if err := conn.WriteJSON(map[string]any{"type": "response.output_text.delta", "response_id": responseID, "delta": "OK"}); err != nil {
return
}
}
if err := conn.WriteMessage(websocket.TextMessage, websocketCompleted(responseID, "")); err != nil {
return
}
}
_, _, _ = conn.ReadMessage()
}))
t.Cleanup(upstream.Close)
endpoint := upstream.URL
if id == channel.OpenAI || id == channel.XAI {
endpoint += "/v1"
}
_, engine, _ := websocketTestHandler(t, endpoint, id)
server := httptest.NewServer(engine)
t.Cleanup(server.Close)
conn := dialGatewayWebsocket(t, server.URL)
for index, stream := range []bool{true, true, false} {
request := map[string]any{"type": "response.create", "model": "public", "input": []any{}, "store": false, "stream": stream}
wantEvents := []string{"response.completed"}
if index == 0 {
request["generate"] = false
} else {
request["previous_response_id"] = fmt.Sprintf("resp_%d", index-1)
wantEvents = []string{"response.output_text.delta", "response.completed"}
}
if err := conn.WriteJSON(request); err != nil {
t.Fatal(err)
}
for _, want := range wantEvents {
var event struct {
Type string `json:"type"`
}
if err := conn.ReadJSON(&event); err != nil || event.Type != want {
t.Fatalf("turn=%d want=%s event=%+v err=%v", index, want, event, err)
}
}
}
})
}
}
func TestWebsocketForkAndParentCacheErrorBoundaries(t *testing.T) {
var sends atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
cache := map[string]string{}
for {
var req struct{ StreamID, Input, Previous string }
var body map[string]json.RawMessage
if conn.ReadJSON(&body) != nil {
return
}
_ = json.Unmarshal(body["stream_id"], &req.StreamID)
_ = json.Unmarshal(body["input"], &req.Input)
_ = json.Unmarshal(body["previous_response_id"], &req.Previous)
n := sends.Add(1)
code := ""
if req.Previous != "" {
parentLane, exists := cache[req.Previous]
if !exists {
code = "previous_response_not_found"
} else if req.Input == "fail" {
code = "invalid_request_error"
if parentLane == req.StreamID {
delete(cache, req.Previous)
}
}
}
var event []byte
if code != "" {
event = []byte(fmt.Sprintf(`{"type":"error","stream_id":%q,"status":400,"error":{"type":"invalid_parameter","code":%q,"message":"fixture failure"}}`, req.StreamID, code))
} else {
id := fmt.Sprintf("resp_%d", n)
cache[id] = req.StreamID
event = websocketCompleted(id, req.StreamID)
}
if conn.WriteMessage(websocket.TextMessage, event) != nil {
return
}
}
}))
defer upstream.Close()
_, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
for _, step := range []struct{ lane, input, parent, want string }{{"a", "parent", "", "response.completed"}, {"b", "fail", "resp_1", "invalid_request_error"}, {"b", "fork", "resp_1", "response.completed"}, {"a", "fail", "resp_1", "invalid_request_error"}, {"a", "again", "resp_1", "previous_response_not_found"}} {
request := map[string]any{"type": "response.create", "stream_id": step.lane, "model": "public", "input": step.input, "store": false}
if step.parent != "" {
request["previous_response_id"] = step.parent
}
if err := conn.WriteJSON(request); err != nil {
t.Fatal(err)
}
var event struct {
Type string `json:"type"`
StreamID string `json:"stream_id"`
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := conn.ReadJSON(&event); err != nil {
t.Fatal(err)
}
got := event.Type
if got == "error" {
got = event.Error.Code
}
if got != step.want || event.StreamID != step.lane {
t.Fatalf("got %+v want %s", event, step.want)
}
}
if sends.Load() != 5 {
t.Fatal("state-dependent turn was replayed or locally fabricated")
}
}
func TestWebsocketQueuedTurnUsesCurrentModelAndRevocation(t *testing.T) {
firstRead, release := make(chan struct{}), make(chan struct{})
models := make(chan string, 2)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for n := 1; n <= 2; n++ {
var req struct {
Model string `json:"model"`
}
if conn.ReadJSON(&req) != nil {
return
}
models <- req.Model
if n == 1 {
close(firstRead)
<-release
}
if conn.WriteMessage(websocket.TextMessage, websocketCompleted(fmt.Sprintf("resp_%d", n), "")) != nil {
return
}
}
_, _, _ = conn.ReadMessage()
}))
defer upstream.Close()
h, engine, input := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
for i := 0; i < 2; i++ {
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"hello"}`))
if i == 0 {
select {
case <-firstRead:
case <-time.After(time.Second):
t.Fatal("first turn missing")
}
}
}
input.Groups[0].Models[0].ID = "new-upstream"
if _, err := h.manager.Publish(input); err != nil {
t.Fatal(err)
}
close(release)
for i := 0; i < 2; i++ {
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatal(err)
}
}
if first, second := <-models, <-models; first != "upstream" || second != "new-upstream" {
t.Fatalf("models=%s,%s", first, second)
}
input.AccessKeys = nil
if _, err := h.manager.Publish(input); err != nil {
t.Fatal(err)
}
if _, _, err := conn.ReadMessage(); err == nil {
t.Fatal("revoked AccessKey kept WS open")
}
}
func TestWebsocketCascadePreservesNativeFlow(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
var req map[string]any
if conn.ReadJSON(&req) != nil {
return
}
if req["model"] != "upstream" || req["stream_id"] != "lane" || req["generate"] != false {
t.Error("cascade changed protocol controls")
}
_ = conn.WriteMessage(websocket.TextMessage, websocketCompleted("resp_cascade", "lane"))
_, _, _ = conn.ReadMessage()
}))
defer upstream.Close()
_, innerEngine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
inner := httptest.NewServer(innerEngine)
defer inner.Close()
h, outerEngine, input := websocketTestHandler(t, inner.URL, channel.GPTLoad)
input.Groups[0].Models = []state.ModelConfig{{ID: "public"}}
if _, err := h.manager.Publish(input); err != nil {
t.Fatal(err)
}
registry := h.registry.(*state.CredentialRegistry)
if err := registry.ReplaceCredentials([]state.CredentialEntry{testCredentialEntry(t, h.encryption, 1, 1, "gl-client")}); err != nil {
t.Fatal(err)
}
outer := httptest.NewServer(outerEngine)
defer outer.Close()
conn, response, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(outer.URL, "http")+"/v1/responses", http.Header{"Authorization": {"Bearer gl-client"}, "Origin": {outer.URL}})
if err != nil {
if response != nil {
_ = response.Body.Close()
}
t.Fatal(err)
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","stream_id":"lane","input":"warm","generate":false}`))
var event struct {
Type string `json:"type"`
StreamID string `json:"stream_id"`
}
if err := conn.ReadJSON(&event); err != nil || event.Type != "response.completed" || event.StreamID != "lane" {
t.Fatalf("cascade=%+v err=%v", event, err)
}
}
func TestWebsocketBoundsNormalizedInputBeforeDispatch(t *testing.T) {
var sends atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if _, _, err = conn.ReadMessage(); err == nil {
sends.Add(1)
_ = conn.WriteMessage(websocket.TextMessage, websocketCompleted("resp_1", ""))
}
_, _, _ = conn.ReadMessage()
}))
defer upstream.Close()
h, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
h.websocketLimits.input = 1024
h.websocketLimits.globalInput = 2048
rules, err := parameteroverride.Compile([]any{map[string]any{"set": map[string]any{"instructions": strings.Repeat("x", 4096)}}})
if err != nil {
t.Fatal(err)
}
group := h.manager.Current().Groups[1]
group.ParameterOverrides = rules
h.manager.Current().Groups[1] = group
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"hello"}`))
var event struct {
Type string `json:"type"`
}
if err := conn.ReadJSON(&event); err != nil {
t.Fatal(err)
}
if event.Type != "error" || sends.Load() != 0 {
t.Fatal("expanded input escaped memory budget")
}
}
func TestWebsocketQueueOverflowClosesWithoutInjectingSameLaneError(t *testing.T) {
started := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if _, _, err = conn.ReadMessage(); err == nil {
close(started)
}
for {
if _, _, err = conn.ReadMessage(); err != nil {
return
}
}
}))
defer upstream.Close()
h, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
h.websocketLimits.pending = 1
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"one"}`))
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("upstream did not start")
}
for i := 0; i < 2; i++ {
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","input":"invalid"}`))
}
if _, body, err := conn.ReadMessage(); err == nil {
t.Fatalf("queue overflow emitted a request event into active turn: %s", body)
}
}
type websocketScriptForwarder struct {
AttemptForwarder
open func(context.Context, ForwardInput) (execution.WebsocketSession, execution.WebsocketResult)
}
func (f websocketScriptForwarder) OpenWebsocket(ctx context.Context, input ForwardInput) (execution.WebsocketSession, execution.WebsocketResult) {
return f.open(ctx, input)
}
type websocketScriptSession struct {
turn func(context.Context, []byte, func(context.Context, []byte) error) execution.WebsocketResult
done chan struct{}
once sync.Once
}
func (s *websocketScriptSession) ExecuteTurn(ctx context.Context, body []byte, emit func(context.Context, []byte) error) execution.WebsocketResult {
return s.turn(ctx, body, emit)
}
func (s *websocketScriptSession) Done() <-chan struct{} { return s.done }
func (s *websocketScriptSession) Close() error { s.once.Do(func() { close(s.done) }); return nil }
func TestWebsocketRetriesOnlyUnsentUnboundTurns(t *testing.T) {
for _, dispatch := range []execution.DispatchState{execution.DispatchNotSent, execution.DispatchMaybeSent} {
t.Run(string(dispatch), func(t *testing.T) {
h, engine, input := websocketTestHandler(t, "http://127.0.0.1:1", channel.CLIProxyAPI)
backup := input.Groups[0]
backup.ID, backup.Name = 2, "backup"
input.Groups = append(input.Groups, backup)
input.Credentials = append(input.Credentials, testCredentialConfig(2, 2))
if _, err := h.manager.Publish(input); err != nil {
t.Fatal(err)
}
registry := h.registry.(*state.CredentialRegistry)
if err := registry.ReplaceCredentials([]state.CredentialEntry{testCredentialEntry(t, h.encryption, 1, 1, "first-key"), testCredentialEntry(t, h.encryption, 2, 2, "second-key")}); err != nil {
t.Fatal(err)
}
sink := &recordingRequestLogSink{}
h.requestLogSink = sink
var opens atomic.Int32
h.forwarder = websocketScriptForwarder{AttemptForwarder: h.forwarder, open: func(context.Context, ForwardInput) (execution.WebsocketSession, execution.WebsocketResult) {
n := opens.Add(1)
session := &websocketScriptSession{done: make(chan struct{})}
session.turn = func(ctx context.Context, body []byte, emit func(context.Context, []byte) error) execution.WebsocketResult {
if n == 1 {
_ = session.Close()
return execution.WebsocketResult{DispatchState: dispatch, Error: &execution.ErrorEvidence{Kind: execution.ErrorKindTransport, Code: "websocket_handshake_failed", OriginHint: execution.ErrorOriginUpstream, ScopeHint: execution.ErrorScopeRequest}}
}
if err := emit(ctx, websocketCompleted("resp_retry", "")); err != nil {
return execution.WebsocketResult{DispatchState: execution.DispatchMaybeSent, Error: &execution.ErrorEvidence{Kind: execution.ErrorKindCanceled}}
}
return execution.WebsocketResult{DispatchState: execution.DispatchMaybeSent}
}
return session, execution.WebsocketResult{DispatchState: execution.DispatchNotSent}
}}
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"hello","store":false}`))
_, _, _ = conn.ReadMessage()
events := waitWebsocketLogs(t, sink, 1)
if dispatch == execution.DispatchNotSent {
if opens.Load() != 2 || events[0].Status != telemetry.RequestStatusSuccess || len(events[0].Attempts) != 2 {
t.Fatalf("unsent recovery opens=%d status=%s attempts=%d", opens.Load(), events[0].Status, len(events[0].Attempts))
}
} else if opens.Load() != 1 {
t.Fatal("maybe-sent turn was replayed")
}
})
}
}
func dialGatewayWebsocket(t *testing.T, endpoint string) *websocket.Conn {
t.Helper()
conn, response, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(endpoint, "http")+"/v1/responses", http.Header{"Authorization": {"Bearer gl-client"}})
if err != nil {
if response != nil {
_ = response.Body.Close()
}
t.Fatalf("gateway WS handshake: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
return conn
}
func TestWebsocketDefaultLaneFIFOAndContinuation(t *testing.T) {
var connections, turns atomic.Int32
started, release := make(chan struct{}), make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
connections.Add(1)
for {
_, body, err := conn.ReadMessage()
if err != nil {
return
}
var req map[string]any
_ = json.Unmarshal(body, &req)
n := turns.Add(1)
if req["model"] != "upstream" || req["type"] != "response.create" {
t.Error("model or protocol not preserved")
}
if n == 1 {
close(started)
<-release
} else if req["previous_response_id"] != "resp_1" {
t.Error("continuation ID lost")
}
if err = conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":"resp_%d","object":"response","status":"completed","model":"upstream","usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`, n))); err != nil {
return
}
}
}))
defer upstream.Close()
_, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"one","store":false}`)); err != nil {
t.Fatal(err)
}
select {
case <-started:
case <-time.After(3 * time.Second):
t.Fatal("first turn not sent")
}
// 本地无效的第二轮仍须排在第一轮终态之后。
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","input":"invalid"}`))
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"two","previous_response_id":"resp_1","store":false}`))
close(release)
for _, want := range []string{"response.completed", "error", "response.completed"} {
_, body, err := conn.ReadMessage()
if err != nil {
t.Fatal(err)
}
var event map[string]any
_ = json.Unmarshal(body, &event)
if event["type"] != want {
t.Fatalf("event=%s want %s", body, want)
}
if want == "response.completed" && event["response"].(map[string]any)["model"] != "public" {
t.Fatal("response alias lost")
}
}
if turns.Load() != 2 || connections.Load() != 1 {
t.Fatalf("turns=%d connections=%d", turns.Load(), connections.Load())
}
}
func TestWebsocketConcurrentFirstTurnsShareOneSession(t *testing.T) {
var connections atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
connections.Add(1)
lanes := []string{}
for len(lanes) < 2 {
var req struct {
StreamID string `json:"stream_id"`
}
if conn.ReadJSON(&req) != nil {
return
}
lanes = append(lanes, req.StreamID)
}
for _, kind := range []string{"response.created", "response.completed"} {
for _, lane := range lanes {
if conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"type":%q,"stream_id":%q,"response":{"id":%q,"object":"response","status":"completed","model":"upstream","usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`, kind, lane, "resp_"+lane))) != nil {
return
}
}
}
_, _, _ = conn.ReadMessage()
}))
defer upstream.Close()
_, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
for _, lane := range []string{"a", "b"} {
if conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"type":"response.create","stream_id":%q,"model":"public","input":"hello"}`, lane))) != nil {
t.Fatal("write")
}
}
seen := map[string]int{}
for i := 0; i < 4; i++ {
var event struct {
StreamID string `json:"stream_id"`
}
if err := conn.ReadJSON(&event); err != nil {
t.Fatal(err)
}
seen[event.StreamID]++
}
if connections.Load() != 1 || seen["a"] != 2 || seen["b"] != 2 {
t.Fatalf("connections=%d events=%v", connections.Load(), seen)
}
}
func TestWebsocketFirstEventTimeoutClosesSession(t *testing.T) {
closed := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
defer close(closed)
for {
if _, _, err = conn.ReadMessage(); err != nil {
return
}
}
}))
defer upstream.Close()
h, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
group := h.manager.Current().Groups[1]
group.Timeouts.FirstByte = 30 * time.Millisecond
h.manager.Current().Groups[1] = group
server := httptest.NewServer(engine)
defer server.Close()
conn := dialGatewayWebsocket(t, server.URL)
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"public","input":"hello"}`))
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("first-event deadline did not stop upstream")
}
}
func TestWebsocketCacheAffinityAndConnectionBindingKinds(t *testing.T) {
var ids atomic.Int64
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
if err := conn.WriteMessage(websocket.TextMessage, websocketCompleted(fmt.Sprintf("cache-%d", ids.Add(1)), "")); err != nil {
return
}
}
}))
defer upstream.Close()
h, engine, _ := websocketTestHandler(t, upstream.URL+"/v1", channel.OpenAI)
sink := &recordingRequestLogSink{}
h.requestLogSink = sink
server := httptest.NewServer(engine)
defer server.Close()
send := func(conn *websocket.Conn, key, prefix string, count int) {
t.Helper()
if err := conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"type":"response.create","model":"public","input":%q,"prompt_cache_key":%q}`, prefix, key))); err != nil {
t.Fatal(err)
}
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatal(err)
}
waitWebsocketLogs(t, sink, count)
}
first := dialGatewayWebsocket(t, server.URL)
send(first, "a", "initial", 1)
first.Close()
second := dialGatewayWebsocket(t, server.URL)
defer second.Close()
send(second, "a", "different", 2)
send(second, "b", "bound turn", 3)
third := dialGatewayWebsocket(t, server.URL)
defer third.Close()
send(third, "b", "another", 4)
events := sink.snapshot()
assertAffinityHits(t, events, []bool{false, true, true, false})
if events[1].AffinityKind != telemetry.AffinityPromptCacheKey || events[2].AffinityKind != telemetry.AffinityResponseContinuity {
t.Fatal("WebSocket affinity kind mismatch")
}
}