Skip to content

Commit bf37fff

Browse files
fix(gateway): extend compact keepalive to non-passthrough OAuth path
The initial implementation only covered the passthrough branch, but production traffic showed compact requests also flow through the standard OAuth HTTP forward path. Add keepalive to both Forward and forwardOpenAIPassthrough, with structured logging for diagnostics.
1 parent 37ae3b2 commit bf37fff

2 files changed

Lines changed: 191 additions & 12 deletions

File tree

backend/internal/service/openai_gateway_service.go

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -443,9 +443,21 @@ func NewOpenAIGatewayService(
443443
openAITokenProvider.SetAccountRuntimeBlocker(svc)
444444
}
445445
svc.logOpenAIWSModeBootstrap()
446+
svc.logOpenAICompactNonstreamKeepaliveBootstrap()
446447
return svc
447448
}
448449

450+
func (s *OpenAIGatewayService) logOpenAICompactNonstreamKeepaliveBootstrap() {
451+
interval := s.compactNonstreamKeepaliveInterval()
452+
if interval <= 0 {
453+
return
454+
}
455+
logger.L().With(
456+
zap.String("component", "service.openai_gateway"),
457+
zap.Int("interval_seconds", int(interval.Seconds())),
458+
).Info("OpenAI compact non-stream keepalive enabled")
459+
}
460+
449461
// ResolveChannelMapping 解析渠道级模型映射(代理到 ChannelService)
450462
func (s *OpenAIGatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult {
451463
if s.channelService == nil {
@@ -2982,25 +2994,42 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
29822994
}
29832995

29842996
// Send request
2997+
stopCompactKeepalive := func() {}
2998+
if !reqStream {
2999+
stopCompactKeepalive = s.startCompactNonstreamKeepalive(ctx, c)
3000+
}
3001+
29853002
upstreamStart := time.Now()
29863003
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
29873004
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
29883005
if err != nil {
3006+
stopCompactKeepalive()
29893007
// Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
29903008
// a failover so the handler switches to a healthy account, and temporarily
29913009
// unschedule the account on durable faults (e.g. rejected proxy credentials).
2992-
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
3010+
transportErr := s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
3011+
if openAICompactKeepaliveCommitted(c) {
3012+
logOpenAICompactKeepaliveCommitted(ctx, c, account, nil)
3013+
writeOpenAICommittedTransportError(c)
3014+
return nil, fmt.Errorf("upstream request failed after compact keepalive: %s", sanitizeUpstreamErrorMessage(err.Error()))
3015+
}
3016+
return nil, transportErr
29933017
}
29943018

29953019
// Handle error response
29963020
if resp.StatusCode >= 400 {
3021+
stopCompactKeepalive()
29973022
respBody := s.readUpstreamErrorBody(resp)
29983023
_ = resp.Body.Close()
29993024
resp.Body = io.NopCloser(bytes.NewReader(respBody))
30003025

30013026
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
30023027
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
30033028
upstreamCode := extractUpstreamErrorCode(respBody)
3029+
if openAICompactKeepaliveCommitted(c) {
3030+
logOpenAICompactKeepaliveCommitted(ctx, c, account, resp)
3031+
return s.handleErrorResponse(ctx, resp, c, account, body, billingModel)
3032+
}
30043033
if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" {
30053034
decoded, decodeErr := ensureReqBody()
30063035
if decodeErr != nil {
@@ -3063,6 +3092,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
30633092
imageCount := 0
30643093
var imageOutputSizes []string
30653094
if reqStream {
3095+
stopCompactKeepalive()
30663096
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel)
30673097
if err != nil {
30683098
return nil, err
@@ -3073,7 +3103,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
30733103
imageCount = streamResult.imageCount
30743104
imageOutputSizes = streamResult.imageOutputSizes
30753105
} else {
3076-
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
3106+
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel, stopCompactKeepalive)
30773107
if err != nil {
30783108
return nil, err
30793109
}
@@ -3686,6 +3716,16 @@ func (s *OpenAIGatewayService) startCompactNonstreamKeepalive(ctx context.Contex
36863716
if ctx == nil {
36873717
ctx = context.Background()
36883718
}
3719+
path := ""
3720+
if c.Request != nil && c.Request.URL != nil {
3721+
path = strings.TrimSpace(c.Request.URL.Path)
3722+
}
3723+
log := logger.FromContext(ctx).With(
3724+
zap.String("component", "service.openai_gateway"),
3725+
zap.String("request_path", path),
3726+
zap.Int("interval_seconds", int(interval.Seconds())),
3727+
)
3728+
log.Info("OpenAI compact non-stream keepalive started")
36893729

36903730
headers := c.Writer.Header()
36913731
headers.Set("Content-Type", "application/json")
@@ -3701,15 +3741,23 @@ func (s *OpenAIGatewayService) startCompactNonstreamKeepalive(ctx context.Contex
37013741
defer wg.Done()
37023742
ticker := time.NewTicker(interval)
37033743
defer ticker.Stop()
3744+
flushedLogged := false
37043745
for {
37053746
select {
37063747
case <-stopCh:
37073748
return
37083749
case <-ctx.Done():
37093750
return
37103751
case <-ticker.C:
3711-
_, _ = c.Writer.Write([]byte("\n"))
3752+
if _, err := c.Writer.Write([]byte("\n")); err != nil {
3753+
log.Warn("OpenAI compact non-stream keepalive write failed", zap.Error(err))
3754+
return
3755+
}
37123756
flusher.Flush()
3757+
if !flushedLogged {
3758+
log.Info("OpenAI compact non-stream keepalive flushed")
3759+
flushedLogged = true
3760+
}
37133761
}
37143762
}
37153763
}()
@@ -5385,17 +5433,22 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) {
53855433
}, true
53865434
}
53875435

5388-
func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) {
5389-
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
5436+
func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string, stopBeforeWrite ...func()) (*openaiNonStreamingResult, error) {
5437+
stop := compactStopFunc(stopBeforeWrite...)
5438+
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, func(c *gin.Context) {
5439+
stop()
5440+
openAITooLargeError(c)
5441+
})
53905442
if err != nil {
5443+
stop()
53915444
return nil, err
53925445
}
53935446

53945447
// Detect SSE responses for ALL account types via Content-Type header.
53955448
// Some OpenAI-compatible upstreams (including other sub2api instances)
53965449
// may return SSE even when stream=false was requested.
53975450
if isEventStreamResponse(resp.Header) {
5398-
return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel)
5451+
return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel, stop)
53995452
}
54005453
bodyLooksLikeSSE := bytes.Contains(body, []byte("data:")) || bytes.Contains(body, []byte("event:"))
54015454

@@ -5405,14 +5458,15 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
54055458
// positives on JSON responses that coincidentally contain "data:" or
54065459
// "event:" in their text content.
54075460
if account.Type == AccountTypeOAuth && bodyLooksLikeSSE {
5408-
return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel)
5461+
return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel, stop)
54095462
}
54105463

54115464
usageValue, usageOK := extractOpenAIUsageFromJSONBytes(body)
54125465
if !usageOK {
54135466
if bodyLooksLikeSSE {
5414-
return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel)
5467+
return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel, stop)
54155468
}
5469+
stop()
54165470
return nil, fmt.Errorf("parse response: invalid json response")
54175471
}
54185472
usage := &usageValue
@@ -5431,6 +5485,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
54315485
}
54325486
}
54335487

5488+
stop()
54345489
c.Data(resp.StatusCode, contentType, body)
54355490

54365491
return &openaiNonStreamingResult{
@@ -5442,12 +5497,20 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
54425497
}, nil
54435498
}
54445499

5500+
func compactStopFunc(stops ...func()) func() {
5501+
if len(stops) == 0 || stops[0] == nil {
5502+
return func() {}
5503+
}
5504+
return stops[0]
5505+
}
5506+
54455507
func isEventStreamResponse(header http.Header) bool {
54465508
contentType := strings.ToLower(header.Get("Content-Type"))
54475509
return strings.Contains(contentType, "text/event-stream")
54485510
}
54495511

5450-
func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) {
5512+
func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string, stopBeforeWrite ...func()) (*openaiNonStreamingResult, error) {
5513+
stop := compactStopFunc(stopBeforeWrite...)
54515514
bodyText := string(body)
54525515
finalResponse, ok := extractCodexFinalResponse(bodyText)
54535516

@@ -5479,6 +5542,7 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
54795542
if msg == "" {
54805543
msg = "Upstream compact response failed"
54815544
}
5545+
stop()
54825546
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
54835547
}
54845548
usage = s.parseSSEUsageFromBody(bodyText)
@@ -5497,6 +5561,7 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
54975561
contentType = "text/event-stream"
54985562
}
54995563
}
5564+
stop()
55005565
c.Data(resp.StatusCode, contentType, body)
55015566

55025567
return &openaiNonStreamingResult{

backend/internal/service/openai_oauth_passthrough_test.go

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ func (u *httpUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID
4747
req.Body = io.NopCloser(bytes.NewReader(b))
4848
}
4949
u.requests = append(u.requests, req)
50-
if u.err != nil {
51-
return nil, u.err
52-
}
5350
if u.delay > 0 {
5451
time.Sleep(u.delay)
5552
}
53+
if u.err != nil {
54+
return nil, u.err
55+
}
5656
if len(u.responses) > 0 {
5757
resp := u.responses[0]
5858
u.responses = u.responses[1:]
@@ -497,6 +497,55 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactNonstreamKeepaliveWritesLe
497497
require.Contains(t, string(body), `"id":"cmp_keepalive"`)
498498
}
499499

500+
func TestOpenAIGatewayService_OAuthCompactNonPassthroughKeepaliveWritesLeadingNewline(t *testing.T) {
501+
gin.SetMode(gin.TestMode)
502+
503+
rec := httptest.NewRecorder()
504+
c, _ := gin.CreateTestContext(rec)
505+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(nil))
506+
c.Request.Header.Set("User-Agent", "Codex Desktop/0.135.0-alpha.1")
507+
c.Request.Header.Set("Content-Type", "application/json")
508+
509+
originalBody := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"local-test-instructions","input":[{"type":"message","role":"user","content":"compact me"}]}`)
510+
resp := &http.Response{
511+
StatusCode: http.StatusOK,
512+
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-compact-native-keepalive"}},
513+
Body: io.NopCloser(strings.NewReader(`{"id":"cmp_native_keepalive","usage":{"input_tokens":11,"output_tokens":22}}`)),
514+
}
515+
upstream := &httpUpstreamRecorder{resp: resp, delay: 1200 * time.Millisecond}
516+
517+
svc := &OpenAIGatewayService{
518+
cfg: &config.Config{Gateway: config.GatewayConfig{
519+
OpenAICompactNonstreamKeepaliveInterval: 1,
520+
}},
521+
httpUpstream: upstream,
522+
}
523+
account := &Account{
524+
ID: 124,
525+
Name: "acc-native",
526+
Platform: PlatformOpenAI,
527+
Type: AccountTypeOAuth,
528+
Concurrency: 1,
529+
Credentials: map[string]any{
530+
"access_token": "oauth-token",
531+
"chatgpt_account_id": "chatgpt-acc",
532+
},
533+
Status: StatusActive,
534+
Schedulable: true,
535+
RateMultiplier: f64p(1),
536+
}
537+
538+
result, err := svc.Forward(context.Background(), c, account, originalBody)
539+
require.NoError(t, err)
540+
require.NotNil(t, result)
541+
body := rec.Body.Bytes()
542+
require.True(t, bytes.HasPrefix(body, []byte("\n")), "native compact keepalive should write a leading blank line")
543+
require.True(t, json.Valid(bytes.TrimSpace(body)))
544+
require.Contains(t, string(body), `"id":"cmp_native_keepalive"`)
545+
require.NotNil(t, upstream.lastReq)
546+
require.Equal(t, chatgptCodexURL+"/compact", upstream.lastReq.URL.String())
547+
}
548+
500549
func TestOpenAIGatewayService_OAuthPassthrough_CompactNonstreamKeepaliveDisabledHasNoLeadingWhitespace(t *testing.T) {
501550
gin.SetMode(gin.TestMode)
502551

@@ -623,6 +672,71 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactNonstreamKeepaliveCommitte
623672
}
624673
}
625674

675+
func TestOpenAIGatewayService_OAuthCompactNonstreamKeepaliveCommittedTransportErrorDoesNotFailover(t *testing.T) {
676+
gin.SetMode(gin.TestMode)
677+
678+
testCases := []struct {
679+
name string
680+
body []byte
681+
account *Account
682+
}{
683+
{
684+
name: "passthrough",
685+
body: []byte(`{"model":"gpt-5.1-codex","stream":true,"instructions":"local-test-instructions","input":[{"type":"text","text":"compact me"}]}`),
686+
account: newOpenAICompactPassthroughTestAccount(),
687+
},
688+
{
689+
name: "non_passthrough",
690+
body: []byte(`{"model":"gpt-5.5","stream":false,"instructions":"local-test-instructions","input":[{"type":"message","role":"user","content":"compact me"}]}`),
691+
account: &Account{
692+
ID: 124,
693+
Name: "acc-native",
694+
Platform: PlatformOpenAI,
695+
Type: AccountTypeOAuth,
696+
Concurrency: 1,
697+
Credentials: map[string]any{
698+
"access_token": "oauth-token",
699+
"chatgpt_account_id": "chatgpt-acc",
700+
},
701+
Status: StatusActive,
702+
Schedulable: true,
703+
RateMultiplier: f64p(1),
704+
},
705+
},
706+
}
707+
708+
for _, tc := range testCases {
709+
t.Run(tc.name, func(t *testing.T) {
710+
rec := httptest.NewRecorder()
711+
c, _ := gin.CreateTestContext(rec)
712+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(nil))
713+
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
714+
c.Request.Header.Set("Content-Type", "application/json")
715+
716+
upstream := &httpUpstreamRecorder{
717+
err: errors.New("dial tcp: i/o timeout"),
718+
delay: 1200 * time.Millisecond,
719+
}
720+
svc := &OpenAIGatewayService{
721+
cfg: &config.Config{Gateway: config.GatewayConfig{
722+
ForceCodexCLI: false,
723+
OpenAICompactNonstreamKeepaliveInterval: 1,
724+
}},
725+
httpUpstream: upstream,
726+
}
727+
728+
result, err := svc.Forward(context.Background(), c, tc.account, tc.body)
729+
require.Error(t, err)
730+
require.Nil(t, result)
731+
var failoverErr *UpstreamFailoverError
732+
require.False(t, errors.As(err, &failoverErr), "committed compact keepalive must suppress transport failover")
733+
body := rec.Body.Bytes()
734+
require.True(t, bytes.HasPrefix(body, []byte("\n")))
735+
require.Contains(t, string(body), "Upstream request failed")
736+
})
737+
}
738+
}
739+
626740
func newOpenAICompactPassthroughTestAccount() *Account {
627741
return &Account{
628742
ID: 123,

0 commit comments

Comments
 (0)