Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion backend/internal/service/codex_ticket_manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
Expand Down
34 changes: 34 additions & 0 deletions backend/internal/service/codex_ticket_manual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
3 changes: 2 additions & 1 deletion backend/internal/service/oauth_refresh_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
31 changes: 31 additions & 0 deletions backend/internal/service/oauth_refresh_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
4 changes: 3 additions & 1 deletion backend/internal/service/openai_token_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading