| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- package service
- import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- coderws "github.com/coder/websocket"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- // openAIWSIngressCapacityShedRepo 补齐 SetError,避免非容量类错误(如
- // workspace_suspended)走到账号状态副作用时打空指针。
- type openAIWSIngressCapacityShedRepo struct {
- stubOpenAIAccountRepo
- }
- func (r *openAIWSIngressCapacityShedRepo) SetError(context.Context, int64, string) error { return nil }
- func (r *openAIWSIngressCapacityShedRepo) SetRateLimited(context.Context, int64, time.Time) error {
- return nil
- }
- func (r *openAIWSIngressCapacityShedRepo) UpdateExtra(context.Context, int64, map[string]any) error {
- return nil
- }
- // ctx_pool 的 ingress 直写路径把 error / response.failed 交给 WS 客户端前,必须和
- // HTTP/SSE(openai_gateway_response_handling.go)与 http_bridge
- // (openai_ws_http_bridge.go)两条路径一样,把容量降载码改写为可重试的
- // server_error:Codex 按闭集判定,server_is_overloaded / slow_down 属致命集,
- // 客户端会打印 "Selected model is at capacity" 并直接终止会话而不是退避重试。
- //
- // 第二个用例锁住改写范围:非容量类错误码必须原样下发,客户端依赖原码各自处理。
- func TestProxyResponsesWebSocketFromClient_RewritesCapacityShedCodeForClient(t *testing.T) {
- gin.SetMode(gin.TestMode)
- tests := []struct {
- name string
- upstreamEvents [][]byte
- wantContains []string
- wantAbsent []string
- }{
- {
- name: "capacity_shed_error_and_failed_are_rewritten",
- upstreamEvents: [][]byte{
- []byte(`{"type":"error","error":{"type":"service_unavailable_error","code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}`),
- []byte(`{"type":"response.failed","response":{"id":"resp_shed","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}}`),
- },
- wantContains: []string{
- `"code":"server_error"`,
- "Our servers are currently overloaded",
- },
- wantAbsent: []string{"server_is_overloaded"},
- },
- {
- name: "non_capacity_error_code_is_passed_through",
- upstreamEvents: [][]byte{
- []byte(`{"type":"error","error":{"type":"invalid_request_error","code":"workspace_suspended","message":"workspace is suspended"}}`),
- []byte(`{"type":"response.failed","response":{"id":"resp_suspended","status":"failed","error":{"code":"workspace_suspended","message":"workspace is suspended"}}}`),
- },
- wantContains: []string{
- `"code":"workspace_suspended"`,
- "workspace is suspended",
- },
- wantAbsent: []string{"server_error"},
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := newOpenAIWSV2TestConfig()
- cfg.Security.URLAllowlist.Enabled = false
- cfg.Security.URLAllowlist.AllowInsecureHTTP = true
- cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
- cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
- cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
- cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
- cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
- cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
- cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
- events := make([][]byte, 0, len(tt.upstreamEvents))
- for _, event := range tt.upstreamEvents {
- events = append(events, append([]byte(nil), event...))
- }
- captureConn := &openAIWSCaptureConn{events: events}
- pool := newOpenAIWSConnPool(cfg)
- pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
- account := Account{
- ID: 5401,
- Name: "openai-ingress-capacity-shed",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test"},
- Extra: map[string]any{"responses_websockets_v2_enabled": true},
- }
- repo := &openAIWSIngressCapacityShedRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}}
- svc := &OpenAIGatewayService{
- accountRepo: repo,
- rateLimitService: &RateLimitService{accountRepo: repo},
- httpUpstream: &httpUpstreamRecorder{},
- cache: &stubGatewayCache{},
- cfg: cfg,
- openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
- toolCorrector: NewCodexToolCorrector(),
- openaiWSPool: pool,
- }
- serverDone := make(chan struct{})
- wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- defer close(serverDone)
- conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
- if err != nil {
- return
- }
- defer func() { _ = conn.CloseNow() }()
- rec := httptest.NewRecorder()
- ginCtx, _ := gin.CreateTestContext(rec)
- req := r.Clone(r.Context())
- req.Header = req.Header.Clone()
- req.Header.Set("User-Agent", "unit-test-agent/1.0")
- ginCtx.Request = req
- readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
- msgType, firstMessage, readErr := conn.Read(readCtx)
- cancel()
- if readErr != nil || (msgType != coderws.MessageText && msgType != coderws.MessageBinary) {
- return
- }
- _ = svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, &account, "sk-test", firstMessage, nil)
- }))
- defer wsServer.Close()
- dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
- clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil)
- cancelDial()
- require.NoError(t, err)
- defer func() { _ = clientConn.CloseNow() }()
- writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
- err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
- cancelWrite()
- require.NoError(t, err)
- var frames []string
- for len(frames) < len(tt.upstreamEvents) {
- readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- _, message, readErr := clientConn.Read(readCtx)
- cancel()
- if readErr != nil {
- break
- }
- frames = append(frames, string(message))
- }
- // 本轮已终止,主动断开客户端让 ingress 退出 turn 循环。
- _ = clientConn.CloseNow()
- require.NotEmpty(t, frames, "客户端应至少收到一个下发事件")
- joined := strings.Join(frames, "\n")
- for _, want := range tt.wantContains {
- require.Contains(t, joined, want, "客户端收到的事件:\n%s", joined)
- }
- for _, absent := range tt.wantAbsent {
- require.NotContains(t, joined, absent, "客户端收到的事件:\n%s", joined)
- }
- select {
- case <-serverDone:
- case <-time.After(5 * time.Second):
- t.Fatal("等待 ingress websocket 结束超时")
- }
- })
- }
- }
- func TestProxyResponsesWebSocketFromClient_MarksCyberPolicyBeforeEarlyReturn(t *testing.T) {
- gin.SetMode(gin.TestMode)
- tests := []struct {
- name string
- upstreamEvent []byte
- wantFailover bool
- wantClientMsg bool
- wantInput int
- wantOutput int
- }{
- {
- name: "error_before_rate_limit_failover",
- upstreamEvent: []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"cyber_policy","message":"rate limit exceeded by cyber policy"},"usage":{"input_tokens":5,"output_tokens":1}}`),
- wantFailover: true,
- wantInput: 5,
- wantOutput: 1,
- },
- {
- name: "response_failed_terminal",
- upstreamEvent: []byte(`{"type":"response.failed","response":{"id":"resp_cyber","status":"failed","error":{"code":"cyber_policy","message":"blocked by cyber policy"},"usage":{"input_tokens":9,"output_tokens":2}}}`),
- wantClientMsg: true,
- wantInput: 9,
- wantOutput: 2,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := newOpenAIWSV2TestConfig()
- cfg.Security.URLAllowlist.Enabled = false
- cfg.Security.URLAllowlist.AllowInsecureHTTP = true
- cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
- cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool
- cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
- captureConn := &openAIWSCaptureConn{events: [][]byte{append([]byte(nil), tt.upstreamEvent...)}}
- pool := newOpenAIWSConnPool(cfg)
- pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
- svc := &OpenAIGatewayService{
- cfg: cfg,
- httpUpstream: &httpUpstreamRecorder{},
- cache: &stubGatewayCache{},
- openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
- toolCorrector: NewCodexToolCorrector(),
- openaiWSPool: pool,
- }
- account := &Account{
- ID: 5402, Name: "openai-ingress-cyber", Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test"},
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeCtxPool,
- },
- }
- markCh := make(chan *CyberPolicyMark, 1)
- serverErrCh := make(chan error, 1)
- wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
- if err != nil {
- serverErrCh <- err
- return
- }
- defer func() { _ = conn.CloseNow() }()
- readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
- _, firstMessage, err := conn.Read(readCtx)
- cancelRead()
- if err != nil {
- serverErrCh <- err
- return
- }
- recorder := httptest.NewRecorder()
- ginCtx, _ := gin.CreateTestContext(recorder)
- ginCtx.Request = r.Clone(r.Context())
- hooks := &OpenAIWSIngressHooks{AfterTurn: func(_ int, _ *OpenAIForwardResult, _ error) {
- markCh <- GetOpsCyberPolicy(ginCtx)
- }}
- serverErrCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, hooks)
- }))
- defer wsServer.Close()
- dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
- clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil)
- cancelDial()
- require.NoError(t, err)
- defer func() { _ = clientConn.CloseNow() }()
- writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
- err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`))
- cancelWrite()
- require.NoError(t, err)
- if tt.wantClientMsg {
- readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
- _, message, readErr := clientConn.Read(readCtx)
- cancelRead()
- require.NoError(t, readErr)
- require.Equal(t, "response.failed", gjson.GetBytes(message, "type").String())
- }
- select {
- case mark := <-markCh:
- require.NotNil(t, mark)
- require.Equal(t, "cyber_policy", mark.Code)
- require.Equal(t, tt.wantInput, mark.UpstreamInTok)
- require.Equal(t, tt.wantOutput, mark.UpstreamOutTok)
- case <-time.After(3 * time.Second):
- t.Fatal("AfterTurn did not observe the cyber mark")
- }
- _ = clientConn.CloseNow()
- select {
- case serverErr := <-serverErrCh:
- var failoverErr *UpstreamFailoverError
- require.Equal(t, tt.wantFailover, errors.As(serverErr, &failoverErr))
- case <-time.After(5 * time.Second):
- t.Fatal("waiting for ingress websocket shutdown timed out")
- }
- })
- }
- }
|