diff --git a/backend/internal/service/codex_ticket_manual.go b/backend/internal/service/codex_ticket_manual.go index fb5c25aab93a..6fa1199d7325 100644 --- a/backend/internal/service/codex_ticket_manual.go +++ b/backend/internal/service/codex_ticket_manual.go @@ -12,6 +12,19 @@ import ( type codexTicketUpstreamError struct{ message string } +// Ticket preparation can refresh credentials without enrolling the account in +// business scheduling or replacing an administrator's status/error decision. +type codexTicketCredentialContextKey struct{} + +func withCodexTicketCredentials(ctx context.Context) context.Context { + return context.WithValue(ctx, codexTicketCredentialContextKey{}, true) +} + +func isCodexTicketCredentialContext(ctx context.Context) bool { + ticket, _ := ctx.Value(codexTicketCredentialContextKey{}).(bool) + return ticket +} + func (e *codexTicketUpstreamError) Error() string { return e.message } // Shared by manual jobs and automatic renewal, across administrators. @@ -114,7 +127,7 @@ func (s *OpenAIGatewayService) runCodexTicketAttempt(parent context.Context, id } defer prepared.transport.CloseIdleConnections() } - token, _, err := s.GetAccessToken(ctx, account) + token, _, err := s.GetAccessToken(withCodexTicketCredentials(ctx), account) if err != nil || strings.TrimSpace(token) == "" { return CodexTicketFailure("ticket_token") } diff --git a/backend/internal/service/codex_ticket_manual_test.go b/backend/internal/service/codex_ticket_manual_test.go index 6690927a8a54..be34080266c3 100644 --- a/backend/internal/service/codex_ticket_manual_test.go +++ b/backend/internal/service/codex_ticket_manual_test.go @@ -227,3 +227,37 @@ func TestCodexTicketEligibilityIndependentOfAccountStatus(t *testing.T) { require.False(t, CodexTicketAccountEligible(a)) } } + +type ticketTokenFailureRepo struct { + *codexTicketMemoryStore + statusWrites int +} + +func (r *ticketTokenFailureRepo) SetError(context.Context, int64, string) error { + r.statusWrites++ + return nil +} + +func TestCodexTicketExpiredCredentialFailurePreservesAccountStatus(t *testing.T) { + a := ticketTestAccount(41) + a.Status, a.Schedulable, a.ErrorMessage = "disabled", false, "original diagnostic" + a.Credentials["expires_at"] = time.Now().Add(-time.Minute).Format(time.RFC3339) + delete(a.Credentials, "refresh_token") + r := &ticketTokenFailureRepo{codexTicketMemoryStore: newCodexTicketMemoryStore(a)} + provider := NewOpenAITokenProvider(r, nil, nil) + s := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080"}, &codexTicketFuncUpstream{do: func(*http.Request) (*http.Response, error) { + t.Fatal("invalid credentials must not reach the upstream") + return nil, nil + }}) + s.accountRepo, s.openAITokenProvider = r, provider + result := s.HarvestCodexTicket(context.Background(), a.ID, "gpt-6-astra", "manual", 0, false) + require.False(t, result.Success) + require.Equal(t, "ticket_token", result.Code) + require.Zero(t, r.statusWrites) + require.Equal(t, "disabled", r.account.Status) + require.False(t, r.account.Schedulable) + require.Equal(t, "original diagnostic", r.account.ErrorMessage) + _, err := provider.GetAccessToken(context.Background(), a) + require.Error(t, err) + require.Equal(t, 1, r.statusWrites, "ordinary inference still quarantines unusable credentials") +} diff --git a/backend/internal/service/oauth_refresh_api.go b/backend/internal/service/oauth_refresh_api.go index c940bd335aef..4bff2dad9108 100644 --- a/backend/internal/service/oauth_refresh_api.go +++ b/backend/internal/service/oauth_refresh_api.go @@ -225,7 +225,8 @@ func (api *OAuthRefreshAPI) RefreshIfNeeded( if freshAccount.ID != account.ID { return nil, fmt.Errorf("%w: account identity mismatch", errOAuthRefreshAccountRereadFailed) } - if !freshAccount.IsActive() { + ticketRefresh := isCodexTicketCredentialContext(ctx) && CodexTicketAccountEligible(freshAccount) + if !freshAccount.IsActive() && !ticketRefresh { if requestPath { return nil, fmt.Errorf("%w: account is not active", errOAuthRefreshAccountStateChanged) } diff --git a/backend/internal/service/oauth_refresh_api_test.go b/backend/internal/service/oauth_refresh_api_test.go index e92bd99f02ea..3baebd4349ba 100644 --- a/backend/internal/service/oauth_refresh_api_test.go +++ b/backend/internal/service/oauth_refresh_api_test.go @@ -208,6 +208,37 @@ func TestRefreshIfNeeded_Success(t *testing.T) { require.Equal(t, 1, executor.refreshCalls) } +func TestRefreshIfNeeded_TicketPreparationAllowsInactiveOpenAIWithoutChangingStatus(t *testing.T) { + for _, status := range []string{"disabled", "error"} { + t.Run(status, func(t *testing.T) { + a := &Account{ID: 41, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: status, Schedulable: false, ErrorMessage: "keep diagnostic", Credentials: map[string]any{"access_token": "old", "refresh_token": "refresh"}} + repo := &refreshAPIAccountRepo{account: a} + executor := &refreshAPIExecutorStub{needsRefresh: true, credentials: map[string]any{"access_token": "new", "refresh_token": "rotated"}} + api := NewOAuthRefreshAPI(repo, nil) + ordinary, err := api.RefreshIfNeeded(context.Background(), a, executor, time.Minute) + require.NoError(t, err) + require.False(t, ordinary.Refreshed) + require.Zero(t, executor.refreshCalls) + result, err := api.RefreshIfNeeded(withCodexTicketCredentials(context.Background()), a, executor, time.Minute) + require.NoError(t, err) + require.True(t, result.Refreshed) + require.Equal(t, 1, executor.refreshCalls) + require.Equal(t, 1, repo.updateCredentialsCalls) + require.Equal(t, status, repo.account.Status) + require.False(t, repo.account.Schedulable) + require.Equal(t, "keep diagnostic", repo.account.ErrorMessage) + }) + } + // A ticket context is not a generic bypass for inactive providers. + a := &Account{ID: 42, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Status: "disabled"} + executor := &refreshAPIExecutorStub{needsRefresh: true} + api := NewOAuthRefreshAPI(&refreshAPIAccountRepo{account: a}, nil) + result, err := api.RefreshIfNeeded(withCodexTicketCredentials(context.Background()), a, executor, time.Minute) + require.NoError(t, err) + require.False(t, result.Refreshed) + require.Zero(t, executor.refreshCalls) +} + func TestRefreshIfNeeded_UpdateCredentialsPreservesRateLimitState(t *testing.T) { resetAt := time.Now().Add(45 * time.Minute) account := &Account{ diff --git a/backend/internal/service/openai_token_provider.go b/backend/internal/service/openai_token_provider.go index b10f52f65ef1..09997616231f 100644 --- a/backend/internal/service/openai_token_provider.go +++ b/backend/internal/service/openai_token_provider.go @@ -162,7 +162,9 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou const reason = "openai access_token expired and refresh_token is missing" // 永久故障:缺失 refresh_token 时账号无法自愈,必须立即从调度池剔除, // 否则会被反复选中、每次都在 token 阶段直接返回错误,对用户呈现持续 502。 - p.disableAccountMissingRefreshToken(account, reason) + if !isCodexTicketCredentialContext(ctx) { + p.disableAccountMissingRefreshToken(account, reason) + } return "", errors.New(reason) } needsRefresh = false