Skip to content

Commit b45774c

Browse files
committed
chore: merge latest upstream fixes
2 parents a9f338c + 1b4029b commit b45774c

55 files changed

Lines changed: 2411 additions & 188 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/internal/handler/admin/system_handler.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ type SystemHandler struct {
2222
lockSvc *service.SystemOperationLockService
2323
}
2424

25+
// systemUpdateTimeout bounds a full in-place update or rollback: the release
26+
// manifest fetch plus a large binary download over slow links. It must stay
27+
// above the GitHub download client timeout (10 minutes) so the download owns
28+
// its own deadline.
29+
const systemUpdateTimeout = 15 * time.Minute
30+
31+
// systemUpdateContext detaches a long-running update/rollback from the HTTP
32+
// request lifetime. Browsers and reverse proxies commonly abort idle requests
33+
// after 30-60s (axios default, nginx proxy_read_timeout), which canceled
34+
// c.Request.Context() mid-download and killed the update with
35+
// "download failed: context canceled" (#4504). The swap keeps running after a
36+
// client disconnect; a later retry then hits the system operation lock or
37+
// reports "Already up to date".
38+
func systemUpdateContext(ctx context.Context) (context.Context, context.CancelFunc) {
39+
base := context.Background()
40+
if ctx != nil {
41+
base = context.WithoutCancel(ctx)
42+
}
43+
return context.WithTimeout(base, systemUpdateTimeout)
44+
}
45+
2546
type systemUpdateService interface {
2647
CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error)
2748
PerformUpdate(ctx context.Context) error
@@ -75,9 +96,12 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) {
7596
release(releaseReason, succeeded)
7697
}()
7798

78-
if err := h.updateSvc.PerformUpdate(ctx); err != nil {
99+
updateCtx, cancel := systemUpdateContext(ctx)
100+
defer cancel()
101+
102+
if err := h.updateSvc.PerformUpdate(updateCtx); err != nil {
79103
if errors.Is(err, service.ErrNoUpdateAvailable) {
80-
info, checkErr := h.updateSvc.CheckUpdate(ctx, false)
104+
info, checkErr := h.updateSvc.CheckUpdate(updateCtx, false)
81105
if checkErr != nil {
82106
releaseReason = "SYSTEM_UPDATE_FAILED"
83107
return nil, checkErr
@@ -152,7 +176,10 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
152176
}()
153177

154178
if targetVersion != "" {
155-
err = h.updateSvc.RollbackToVersion(ctx, targetVersion)
179+
// 指定版本回退同样要下载完整二进制,与更新一样和请求生命周期解耦。
180+
rollbackCtx, cancel := systemUpdateContext(ctx)
181+
defer cancel()
182+
err = h.updateSvc.RollbackToVersion(rollbackCtx, targetVersion)
156183
} else {
157184
err = h.updateSvc.Rollback()
158185
}

backend/internal/handler/admin/system_handler_test.go

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,33 @@ import (
1818
)
1919

2020
type systemHandlerUpdateServiceStub struct {
21-
performErr error
22-
updateInfo *service.UpdateInfo
23-
checkErr error
24-
checkForces []bool
25-
performCall int
26-
rollbackCall int
27-
rollbackToCall int
28-
rollbackToVersions []string
29-
rollbackToErr error
30-
rollbackVersions []service.RollbackVersion
31-
rollbackVersionsErr error
32-
rollbackVersionsCall int
21+
performErr error
22+
updateInfo *service.UpdateInfo
23+
checkErr error
24+
checkForces []bool
25+
performCall int
26+
performCtxErr error
27+
performHasDeadline bool
28+
rollbackCall int
29+
rollbackToCall int
30+
rollbackToCtxErr error
31+
rollbackToHasDeadline bool
32+
rollbackToVersions []string
33+
rollbackToErr error
34+
rollbackVersions []service.RollbackVersion
35+
rollbackVersionsErr error
36+
rollbackVersionsCall int
3337
}
3438

3539
func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) {
3640
s.checkForces = append(s.checkForces, force)
3741
return s.updateInfo, s.checkErr
3842
}
3943

40-
func (s *systemHandlerUpdateServiceStub) PerformUpdate(context.Context) error {
44+
func (s *systemHandlerUpdateServiceStub) PerformUpdate(ctx context.Context) error {
4145
s.performCall++
46+
s.performCtxErr = ctx.Err()
47+
_, s.performHasDeadline = ctx.Deadline()
4248
return s.performErr
4349
}
4450

@@ -52,8 +58,10 @@ func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) (
5258
return s.rollbackVersions, s.rollbackVersionsErr
5359
}
5460

55-
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(_ context.Context, version string) error {
61+
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(ctx context.Context, version string) error {
5662
s.rollbackToCall++
63+
s.rollbackToCtxErr = ctx.Err()
64+
_, s.rollbackToHasDeadline = ctx.Deadline()
5765
s.rollbackToVersions = append(s.rollbackToVersions, version)
5866
return s.rollbackToErr
5967
}
@@ -165,6 +173,55 @@ func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T
165173
require.Equal(t, "internal error", body.Message)
166174
}
167175

176+
// TestSystemHandlerPerformUpdateSurvivesClientDisconnect reproduces #4504:
177+
// the browser or a reverse proxy (axios 30s default, nginx proxy_read_timeout
178+
// 60s) aborts the long-running update request and cancels the request
179+
// context. The download must keep running on a detached, bounded context
180+
// instead of dying with "download failed: context canceled".
181+
func TestSystemHandlerPerformUpdateSurvivesClientDisconnect(t *testing.T) {
182+
updateSvc := &systemHandlerUpdateServiceStub{}
183+
repo := newMemoryIdempotencyRepoStub()
184+
router := newSystemHandlerTestRouter(t, updateSvc, repo)
185+
186+
rec := httptest.NewRecorder()
187+
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
188+
canceledCtx, cancel := context.WithCancel(context.Background())
189+
cancel()
190+
req = req.WithContext(canceledCtx)
191+
req.Header.Set("Idempotency-Key", "disconnected-update")
192+
router.ServeHTTP(rec, req)
193+
194+
require.Equal(t, 1, updateSvc.performCall)
195+
require.NoError(t, updateSvc.performCtxErr,
196+
"update must not observe the canceled request context")
197+
require.True(t, updateSvc.performHasDeadline,
198+
"detached update context must still be bounded by a deadline")
199+
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
200+
}
201+
202+
func TestSystemHandlerRollbackToVersionSurvivesClientDisconnect(t *testing.T) {
203+
updateSvc := &systemHandlerUpdateServiceStub{}
204+
repo := newMemoryIdempotencyRepoStub()
205+
router := newSystemHandlerTestRouter(t, updateSvc, repo)
206+
207+
rec := httptest.NewRecorder()
208+
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
209+
strings.NewReader(`{"version":"0.1.146"}`))
210+
req.Header.Set("Content-Type", "application/json")
211+
canceledCtx, cancel := context.WithCancel(context.Background())
212+
cancel()
213+
req = req.WithContext(canceledCtx)
214+
req.Header.Set("Idempotency-Key", "disconnected-rollback")
215+
router.ServeHTTP(rec, req)
216+
217+
require.Equal(t, 1, updateSvc.rollbackToCall)
218+
require.NoError(t, updateSvc.rollbackToCtxErr,
219+
"versioned rollback must not observe the canceled request context")
220+
require.True(t, updateSvc.rollbackToHasDeadline,
221+
"detached rollback context must still be bounded by a deadline")
222+
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
223+
}
224+
168225
func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup(t *testing.T) {
169226
updateSvc := &systemHandlerUpdateServiceStub{}
170227
repo := newMemoryIdempotencyRepoStub()

backend/internal/handler/failover_loop.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ func (s *FailoverState) HandleFailoverError(
8383
return FailoverExhausted
8484
}
8585

86-
// 缓存计费判断
87-
if needForceCacheBilling(s.hasBoundSession, failoverErr) {
86+
// 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。
87+
sameAccountRetry := failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit
88+
if needForceCacheBilling(s.hasBoundSession, failoverErr, sameAccountRetry) {
8889
s.ForceCacheBilling = true
8990
}
9091

@@ -174,9 +175,9 @@ func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAc
174175
}
175176

176177
// needForceCacheBilling 判断 failover 时是否需要强制缓存计费。
177-
// 粘性会话切换账号、或上游明确标记时,将 input_tokens 转为 cache_read 计费。
178-
func needForceCacheBilling(hasBoundSession bool, failoverErr *service.UpstreamFailoverError) bool {
179-
return hasBoundSession || (failoverErr != nil && failoverErr.ForceCacheBilling)
178+
// 粘性会话实际切换账号、或上游明确标记时,将 input_tokens 转为 cache_read 计费。
179+
func needForceCacheBilling(hasBoundSession bool, failoverErr *service.UpstreamFailoverError, sameAccountRetry bool) bool {
180+
return (hasBoundSession && !sameAccountRetry) || (failoverErr != nil && failoverErr.ForceCacheBilling)
180181
}
181182

182183
// failoverClientGone 判断下游客户端是否已断开(请求 context 已取消)。

backend/internal/handler/failover_loop_test.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ func TestHandleFailoverError_BasicSwitch(t *testing.T) {
271271
// ---------------------------------------------------------------------------
272272

273273
func TestHandleFailoverError_CacheBilling(t *testing.T) {
274-
t.Run("hasBoundSession为true时设置ForceCacheBilling", func(t *testing.T) {
274+
t.Run("hasBoundSession为true且实际切换时设置ForceCacheBilling", func(t *testing.T) {
275275
mock := &mockTempUnscheduler{}
276276
fs := NewFailoverState(3, true) // hasBoundSession=true
277277
err := newTestFailoverErr(500, false, false)
@@ -280,6 +280,32 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
280280
require.True(t, fs.ForceCacheBilling)
281281
})
282282

283+
t.Run("同账号重试时仅凭hasBoundSession不设置ForceCacheBilling", func(t *testing.T) {
284+
mock := &mockTempUnscheduler{}
285+
fs := NewFailoverState(3, true)
286+
err := newTestFailoverErr(400, true, false)
287+
288+
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
289+
290+
require.False(t, fs.ForceCacheBilling)
291+
require.Zero(t, fs.SwitchCount)
292+
})
293+
294+
t.Run("同账号重试耗尽并实际切换时设置ForceCacheBilling", func(t *testing.T) {
295+
mock := &mockTempUnscheduler{}
296+
fs := NewFailoverState(3, true)
297+
err := newTestFailoverErr(400, true, false)
298+
299+
for i := 0; i < maxSameAccountRetries; i++ {
300+
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
301+
require.False(t, fs.ForceCacheBilling)
302+
}
303+
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
304+
305+
require.True(t, fs.ForceCacheBilling)
306+
require.Equal(t, 1, fs.SwitchCount)
307+
})
308+
283309
t.Run("failoverErr.ForceCacheBilling为true时设置", func(t *testing.T) {
284310
mock := &mockTempUnscheduler{}
285311
fs := NewFailoverState(3, false)
@@ -289,6 +315,17 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
289315
require.True(t, fs.ForceCacheBilling)
290316
})
291317

318+
t.Run("同账号重试保留显式ForceCacheBilling", func(t *testing.T) {
319+
mock := &mockTempUnscheduler{}
320+
fs := NewFailoverState(3, true)
321+
err := newTestFailoverErr(400, true, true)
322+
323+
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
324+
325+
require.True(t, fs.ForceCacheBilling)
326+
require.Zero(t, fs.SwitchCount)
327+
})
328+
292329
t.Run("两者均为false时不设置", func(t *testing.T) {
293330
mock := &mockTempUnscheduler{}
294331
fs := NewFailoverState(3, false)
@@ -633,13 +670,14 @@ func TestHandleFailoverError_IntegrationScenario(t *testing.T) {
633670
for i := 0; i < maxSameAccountRetries; i++ {
634671
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, retryErr)
635672
require.Equal(t, FailoverContinue, action)
673+
require.False(t, fs.ForceCacheBilling, "同账号重试期间不应仅因绑定会话强制缓存计费")
636674
}
637-
require.True(t, fs.ForceCacheBilling, "hasBoundSession=true 应设置 ForceCacheBilling")
638675

639676
// 2. 账号 100 超过重试上限 → TempUnschedule + 切换
640677
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, retryErr)
641678
require.Equal(t, FailoverContinue, action)
642679
require.Equal(t, 1, fs.SwitchCount)
680+
require.True(t, fs.ForceCacheBilling, "实际切换账号时应设置 ForceCacheBilling")
643681
require.Len(t, mock.calls, 1)
644682

645683
// 3. 账号 200 遇到不可重试错误 → 直接切换

backend/internal/handler/openai_codex_models_handler.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
5353
h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI accounts")
5454
return
5555
}
56+
// 让 ops 错误日志携带实际选中的上游账号,便于定位失效账号(#4544)。
57+
setOpsSelectedAccount(c, account.ID, account.Platform)
5658

5759
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match"))
5860
if err != nil {

backend/internal/handler/openai_gateway_handler.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ func openAICompatibleRequestPlatform(apiKey *service.APIKey) string {
124124
return service.PlatformOpenAI
125125
}
126126

127+
func openAIResponsesRequiredCapability(imageIntent bool, platform string) service.OpenAIEndpointCapability {
128+
if imageIntent && platform == service.PlatformOpenAI {
129+
return service.OpenAIEndpointCapabilityResponses
130+
}
131+
return service.OpenAIEndpointCapabilityChatCompletions
132+
}
133+
127134
func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool {
128135
if apiKey == nil || apiKey.Group == nil {
129136
return true
@@ -363,12 +370,9 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
363370
// 生图意图的 /v1/responses 请求必须调度到确实支持 Responses API 的账号,否则
364371
// 会在 forward 阶段被静默降级为无法生图的 Chat Completions 直转(#4417)。
365372
// 仅对 OpenAI 平台生效:Grok 生图走独立的 forwardGrokResponses 路径,不应被过滤。
366-
// 使用 IsExplicitImageGenerationIntent 排除被动 image_gen namespace 声明,
367-
// 避免 Codex 的被动工具目录使 CC-only 账号被误过滤(#4476)。
368-
requiredCapability := service.OpenAIEndpointCapabilityChatCompletions
369-
if service.IsExplicitImageGenerationIntent("/v1/responses", reqModel, body) && requestPlatform == service.PlatformOpenAI {
370-
requiredCapability = service.OpenAIEndpointCapabilityResponses
371-
}
373+
// 复用前置权限与并发阶段在未修改 body 上确认的显式生图意图,避免大 tools 请求重复扫描。
374+
// 该判断已排除 Codex 被动 image_gen namespace,避免 CC-only 账号被误过滤(#4476)。
375+
requiredCapability := openAIResponsesRequiredCapability(imageIntent, requestPlatform)
372376

373377
for {
374378
// Streaming Forward intentionally detaches the upstream request so usage can

backend/internal/handler/openai_gateway_handler_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,39 @@ func TestOpenAIForwardSucceededForScheduling(t *testing.T) {
138138
}))
139139
}
140140

141+
func TestOpenAIResponsesRequiredCapability(t *testing.T) {
142+
tests := []struct {
143+
name string
144+
imageIntent bool
145+
platform string
146+
want service.OpenAIEndpointCapability
147+
}{
148+
{
149+
name: "OpenAI explicit image intent requires Responses",
150+
imageIntent: true,
151+
platform: service.PlatformOpenAI,
152+
want: service.OpenAIEndpointCapabilityResponses,
153+
},
154+
{
155+
name: "Grok explicit image intent keeps chat capability",
156+
imageIntent: true,
157+
platform: service.PlatformGrok,
158+
want: service.OpenAIEndpointCapabilityChatCompletions,
159+
},
160+
{
161+
name: "non-image intent keeps chat capability",
162+
platform: service.PlatformOpenAI,
163+
want: service.OpenAIEndpointCapabilityChatCompletions,
164+
},
165+
}
166+
167+
for _, tt := range tests {
168+
t.Run(tt.name, func(t *testing.T) {
169+
require.Equal(t, tt.want, openAIResponsesRequiredCapability(tt.imageIntent, tt.platform))
170+
})
171+
}
172+
}
173+
141174
func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *testing.T) {
142175
body := []byte(`{"model":"claude-sonnet-4-5","metadata":{"user_id":"claude-code-session"},"messages":[{"role":"user","content":"hello"}]}`)
143176

0 commit comments

Comments
 (0)