diff --git a/.gitignore b/.gitignore index 5dafefb36b..5a7dd4e8c9 100644 --- a/.gitignore +++ b/.gitignore @@ -142,11 +142,14 @@ docs/* !docs/COMPOSITE_GROUPS.md !docs/PLUGIN_DEVELOPMENT.md !docs/channel-monitor-v2-safe-defaults.md +!docs/codex-credits.md !docs/legal/ !docs/legal/*.md !docs/screenshots/ docs/screenshots/* !docs/screenshots/mobile-account-actions-menu.png +!docs/screenshots/codex-referral-dialog.png +!docs/screenshots/codex-referral-success.png .serena/ .codex/ frontend/coverage/ diff --git a/Makefile b/Makefile index fec155a8dc..b99d6ca022 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,9 @@ FRONTEND_CRITICAL_VITEST := \ src/api/__tests__/client.spec.ts \ src/api/__tests__/tokenRefresh.spec.ts \ src/api/__tests__/keys.bulkUpdate.spec.ts \ + src/components/account/__tests__/OpenAIReferralCell.spec.ts \ + src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts \ + src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts \ src/components/keys/__tests__/BulkEditKeysModal.spec.ts \ src/views/user/__tests__/KeysView.spec.ts \ src/api/__tests__/channelMonitorV2.spec.ts \ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 1c28eeb1a4..09a213e952 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -199,14 +199,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig) grokQuotaFetcher := service.NewGrokQuotaFetcher() grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService) - openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService) + openAIReferralClient := repository.NewOpenAIReferralClient(privacyClientFactory) + openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIReferralClient, openAIGatewayService) usageCache := service.NewUsageCache() accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService) pluginRepository := repository.NewPluginRepository(db) pluginHostInfo := providePluginHostInfo(buildInfo) pluginKVStore := repository.NewPluginKVStore(redisClient) - pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore) - pluginManager.SetAccountDirectory(openAIGatewayService) + pluginManager := service.ProvidePluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore, openAIGatewayService) accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index 031fe437fd..b96bf426a6 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -21,12 +21,14 @@ type OpenAIOAuthHandler struct { openaiOAuthService *service.OpenAIOAuthService adminService service.AdminService quotaService openAIQuotaService + referralService openAIReferralService rateLimitService openAIAccountStateRecoverer } type openAIQuotaService interface { QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error) CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error + CacheCreditsSnapshot(ctx context.Context, accountID int64, usage *service.OpenAIQuotaUsage) error CachePostResetSnapshot(ctx context.Context, accountID int64, usage *service.OpenAIQuotaUsage) error ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error) } @@ -57,7 +59,8 @@ type openAIQuotaResetResponse struct { // failed display-cache write must never discard a successful upstream read. type openAIQuotaRefreshResponse struct { service.OpenAIQuotaUsage - CachePersisted bool `json:"cache_persisted"` + CachePersisted bool `json:"cache_persisted"` + CreditsCachePersisted bool `json:"credits_cache_persisted"` } // openAIQuotaResetPostProcessContext detaches the post-reset bookkeeping from the @@ -92,6 +95,7 @@ func NewOpenAIOAuthHandler( // `== nil` capability guards below and panic instead of returning 400. if quotaService != nil { h.quotaService = quotaService + h.referralService = quotaService } if rateLimitService != nil { h.rateLimitService = rateLimitService @@ -522,6 +526,11 @@ func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) { service.NotifyOpenAIAutoResetCredit(accountID) refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage} + if err := h.quotaService.CacheCreditsSnapshot(c.Request.Context(), accountID, usage); err != nil { + slog.Warn("openai_quota_credits_cache_persist_failed", "account_id", accountID, "error", err) + } else { + refreshResponse.CreditsCachePersisted = true + } // A failed snapshot write leaves the previous cache intact — report it as a // partial success instead of discarding the usage payload we just fetched, // which would leave the card without a credit count at all. diff --git a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go index 90677b3bf4..0556a02f89 100644 --- a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go +++ b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go @@ -17,11 +17,14 @@ import ( ) type openAIQuotaWorkflowStub struct { - resetResult *service.OpenAIQuotaResetResult - resetErr error - queryResult *service.OpenAIQuotaUsage - queryErr error - cacheErr error + resetResult *service.OpenAIQuotaResetResult + resetErr error + queryResult *service.OpenAIQuotaUsage + queryErr error + cacheErr error + creditsCacheErr error + creditsCacheCalls int + cachedCreditsUsage *service.OpenAIQuotaUsage resetCalls int queryCalls int @@ -48,6 +51,12 @@ func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context, return s.cacheErr } +func (s *openAIQuotaWorkflowStub) CacheCreditsSnapshot(_ context.Context, _ int64, usage *service.OpenAIQuotaUsage) error { + s.creditsCacheCalls++ + s.cachedCreditsUsage = usage + return s.creditsCacheErr +} + func (s *openAIQuotaWorkflowStub) CachePostResetSnapshot(ctx context.Context, _ int64, _ *service.OpenAIQuotaUsage) error { s.cacheCalls++ s.cacheCtxErr = ctx.Err() @@ -410,6 +419,34 @@ func TestOpenAIRefreshQuota_PersistFailureStillReturnsUsage(t *testing.T) { require.Equal(t, 1, quota.cacheCalls) } +func TestOpenAIRefreshQuota_CreditsPersistIndependently(t *testing.T) { + for _, tc := range []struct { + name string + resetErr error + creditsErr error + }{ + {name: "both saved"}, + {name: "reset details missing", resetErr: errors.New("missing expirations")}, + {name: "points cache failed", creditsErr: errors.New("write failed")}, + } { + t.Run(tc.name, func(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + balance := "1250.75" + quota.queryResult.Credits = &service.OpenAICredits{HasCredits: true, Balance: &balance} + quota.cacheErr = tc.resetErr + quota.creditsCacheErr = tc.creditsErr + status, envelope := performOpenAIQuotaRefreshRequest(t, &OpenAIOAuthHandler{quotaService: quota}) + require.Equal(t, http.StatusOK, status) + require.Equal(t, tc.resetErr == nil, envelope.Data.CachePersisted) + require.Equal(t, tc.creditsErr == nil, envelope.Data.CreditsCachePersisted) + require.Equal(t, quota.queryResult.Credits, envelope.Data.Credits) + require.Equal(t, quota.queryResult, quota.cachedCreditsUsage) + require.Equal(t, 1, quota.creditsCacheCalls) + require.Zero(t, quota.resetCalls) + }) + } +} + // An empty-but-successful upstream read must not be dereferenced blindly. func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) { t.Run("refresh reports an internal error", func(t *testing.T) { diff --git a/backend/internal/handler/admin/openai_referral_handler.go b/backend/internal/handler/admin/openai_referral_handler.go new file mode 100644 index 0000000000..42a2226e58 --- /dev/null +++ b/backend/internal/handler/admin/openai_referral_handler.go @@ -0,0 +1,101 @@ +package admin + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type openAIReferralService interface { + QueryReferralEligibility(context.Context, int64) (*service.OpenAIReferralEligibility, error) + CacheReferralSnapshot(context.Context, int64, *service.OpenAIReferralEligibility) error + SendReferralInvite(context.Context, int64, service.OpenAIReferralSendRequest) (*service.OpenAIReferralSendResult, error) +} + +type openAIReferralRefreshResponse struct { + Eligibility *service.OpenAIReferralEligibility `json:"eligibility"` + CachePersisted bool `json:"cache_persisted"` +} + +type openAIReferralSendResponse struct { + service.OpenAIReferralSendResult + openAIReferralRefreshResponse + RefreshFailed bool `json:"refresh_failed"` +} + +func (h *OpenAIOAuthHandler) referralAccountID(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid account ID") + return 0, false + } + if h.referralService == nil { + response.BadRequest(c, "OpenAI referral service is not enabled") + return 0, false + } + return id, true +} + +// RefreshReferrals persists a display snapshot, hence POST and admin audit. +func (h *OpenAIOAuthHandler) RefreshReferrals(c *gin.Context) { + id, ok := h.referralAccountID(c) + if !ok { + return + } + eligibility, err := h.referralService.QueryReferralEligibility(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + if eligibility == nil { + response.Error(c, http.StatusBadGateway, "Empty invitation eligibility response") + return + } + cacheErr := h.referralService.CacheReferralSnapshot(c.Request.Context(), id, eligibility) + response.Success(c, openAIReferralRefreshResponse{Eligibility: eligibility, CachePersisted: cacheErr == nil}) +} + +func (h *OpenAIOAuthHandler) SendReferralInvite(c *gin.Context) { + id, ok := h.referralAccountID(c) + if !ok { + return + } + var input service.OpenAIReferralSendRequest + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, "Invalid invitation request") + return + } + result, err := h.referralService.SendReferralInvite(c.Request.Context(), id, input) + if err != nil { + response.ErrorFrom(c, err) + return + } + if result == nil || !result.Sent { + response.Error(c, http.StatusBadGateway, "Invitation outcome is unknown; check Codex before sending again") + return + } + // The email is already sent. Refresh failure must not turn it into a failed + // submission and encourage a duplicate send, even if the browser disconnects. + baseCtx := context.WithoutCancel(c.Request.Context()) + refreshCtx, cancelRefresh := context.WithTimeout(baseCtx, 8*time.Second) + eligibility, refreshErr := h.referralService.QueryReferralEligibility(refreshCtx, id) + cancelRefresh() + if refreshErr != nil { + eligibility = nil + } + // Refresh may exhaust its entire deadline. Give persistence (including + // invalidating a stale snapshot) a fresh, independent deadline. + cacheCtx, cancelCache := context.WithTimeout(baseCtx, 3*time.Second) + defer cancelCache() + cacheErr := h.referralService.CacheReferralSnapshot(cacheCtx, id, eligibility) + response.Success(c, openAIReferralSendResponse{ + OpenAIReferralSendResult: *result, + openAIReferralRefreshResponse: openAIReferralRefreshResponse{Eligibility: eligibility, CachePersisted: cacheErr == nil}, + RefreshFailed: eligibility == nil, + }) +} diff --git a/backend/internal/handler/admin/openai_referral_handler_test.go b/backend/internal/handler/admin/openai_referral_handler_test.go new file mode 100644 index 0000000000..5d0b7ad794 --- /dev/null +++ b/backend/internal/handler/admin/openai_referral_handler_test.go @@ -0,0 +1,133 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type referralHandlerStub struct { + query func(context.Context) (*service.OpenAIReferralEligibility, error) + cache func(context.Context, *service.OpenAIReferralEligibility) error + eligibility *service.OpenAIReferralEligibility + queryErr, cacheErr error + input service.OpenAIReferralSendRequest + cached *service.OpenAIReferralEligibility + sends int +} + +func (s *referralHandlerStub) QueryReferralEligibility(ctx context.Context, _ int64) (*service.OpenAIReferralEligibility, error) { + if s.query != nil { + return s.query(ctx) + } + return s.eligibility, s.queryErr +} +func (s *referralHandlerStub) CacheReferralSnapshot(ctx context.Context, _ int64, e *service.OpenAIReferralEligibility) error { + if s.cache != nil { + return s.cache(ctx, e) + } + s.cached = e + return s.cacheErr +} +func (s *referralHandlerStub) SendReferralInvite(_ context.Context, _ int64, input service.OpenAIReferralSendRequest) (*service.OpenAIReferralSendResult, error) { + s.sends++ + s.input = input + return &service.OpenAIReferralSendResult{Email: input.Email, Sent: true}, nil +} + +func referralHandlerRequest(t *testing.T, stub *referralHandlerStub, path, body string) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + h := &OpenAIOAuthHandler{referralService: stub} + router := gin.New() + router.POST("/:id/referrals/refresh", h.RefreshReferrals) + router.POST("/:id/referrals/invite", h.SendReferralInvite) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))) + return rec +} + +func TestOpenAIReferralHandlerRefresh(t *testing.T) { + count := 2 + stub := &referralHandlerStub{eligibility: &service.OpenAIReferralEligibility{ShouldShow: true, AvailableInvites: &count}, cacheErr: errors.New("cache failed")} + rec := referralHandlerRequest(t, stub, "/100/referrals/refresh", "") + require.Equal(t, http.StatusOK, rec.Code) + var body struct { + Data openAIReferralRefreshResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, 2, *body.Data.Eligibility.AvailableInvites) + require.False(t, body.Data.CachePersisted) + require.Zero(t, stub.sends) +} + +func TestOpenAIReferralHandlerSentSurvivesRefreshFailure(t *testing.T) { + stub := &referralHandlerStub{queryErr: errors.New("upstream timed out")} + rec := referralHandlerRequest(t, stub, "/100/referrals/invite", `{"email":"friend@example.com","program_id":"codex_referral_consumer","confirmed":true}`) + require.Equal(t, http.StatusOK, rec.Code) + var body struct { + Data openAIReferralSendResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.True(t, body.Data.Sent) + require.Equal(t, "friend@example.com", body.Data.Email) + require.True(t, body.Data.RefreshFailed) + require.True(t, body.Data.CachePersisted) + require.Nil(t, stub.cached, "stale remaining count must be invalidated") + require.True(t, stub.input.Confirmed) + require.Equal(t, 1, stub.sends) +} + +func TestOpenAIReferralHandlerRejectsInvalidInput(t *testing.T) { + stub := &referralHandlerStub{} + for _, path := range []string{"/invalid/referrals/invite", "/0/referrals/invite", "/100/referrals/invite"} { + rec := referralHandlerRequest(t, stub, path, "not-json") + require.Equal(t, http.StatusBadRequest, rec.Code) + } + require.Zero(t, stub.sends) +} + +func TestOpenAIReferralHandlerInvalidatesCacheAfterRefreshDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + cached := false + stub := &referralHandlerStub{ + query: func(ctx context.Context) (*service.OpenAIReferralEligibility, error) { + <-ctx.Done() + require.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + return nil, ctx.Err() + }, + cache: func(ctx context.Context, e *service.OpenAIReferralEligibility) error { + require.NoError(t, ctx.Err(), "cache invalidation needs a fresh deadline") + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.Equal(t, 3*time.Second, time.Until(deadline)) + require.Nil(t, e, "expired count must be cleared") + cached = true + return nil + }, + } + rec := referralHandlerRequest(t, stub, "/100/referrals/invite", `{"email":"friend@example.com","program_id":"codex_referral_consumer","confirmed":true}`) + require.Equal(t, http.StatusOK, rec.Code) + var body struct { + Data openAIReferralSendResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.True(t, cached) + require.True(t, body.Data.Sent) + require.True(t, body.Data.RefreshFailed) + require.True(t, body.Data.CachePersisted) + require.Equal(t, 1, stub.sends) + }) +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 1c0c34fb0e..8b8c82dd66 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -65,6 +65,8 @@ var schedulerNeutralExtraKeyPrefixes = []string{ var schedulerNeutralExtraKeys = map[string]struct{}{ "codex_usage_updated_at": {}, + "codex_credits_snapshot": {}, + "codex_referral_snapshot": {}, "grok_billing_snapshot": {}, "session_window_utilization": {}, } diff --git a/backend/internal/repository/account_repo_codex_display_snapshot_test.go b/backend/internal/repository/account_repo_codex_display_snapshot_test.go new file mode 100644 index 0000000000..0388106038 --- /dev/null +++ b/backend/internal/repository/account_repo_codex_display_snapshot_test.go @@ -0,0 +1,55 @@ +package repository + +import ( + "context" + "encoding/json" + "regexp" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestUpdateExtraCodexDisplaySnapshotsAvoidSchedulerOutbox(t *testing.T) { + for _, key := range []string{"codex_credits_snapshot", "codex_referral_snapshot"} { + for _, tc := range []struct { + name string + value any + schedulingChange bool + }{ + {"refresh", map[string]any{"fetched_at": 1770000000}, false}, + {"invalidate", nil, false}, + {"mixed scheduling update", nil, true}, + } { + t.Run(key+"/"+tc.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + repo := newAccountRepositoryWithSQL(client, db, nil) + updates := map[string]any{key: tc.value} + if tc.schedulingChange { + updates["openai_compact_enabled"] = true + mock.ExpectBegin() + } + payload, err := json.Marshal(updates) + require.NoError(t, err) + mock.ExpectExec(regexp.QuoteMeta("UPDATE accounts SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL")). + WithArgs(string(payload), int64(27)).WillReturnResult(sqlmock.NewResult(0, 1)) + if tc.schedulingChange { + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WithArgs(service.SchedulerOutboxEventAccountChanged, int64(27), nil, nil, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + } + // Snapshot-only changes must perform just the UPDATE, without even a + // transaction or account_changed event that would rebuild scheduler buckets. + require.NoError(t, repo.UpdateExtra(context.Background(), 27, updates)) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } + } +} diff --git a/backend/internal/repository/openai_referral_client.go b/backend/internal/repository/openai_referral_client.go new file mode 100644 index 0000000000..5589986a54 --- /dev/null +++ b/backend/internal/repository/openai_referral_client.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "encoding/json" + "net/http" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/imroc/req/v3" +) + +// Desktop 26.908.40834, app-primary-44ec287874b7.js: SIt, EIt, PIt, FIt. +// Referrals use /backend-api/referrals, not /backend-api/wham. +const openAIReferralURL = "https://chatgpt.com/backend-api/referrals/invite" +const openAIReferralEntrypoint = "persistent" + +type openAIReferralClient struct { + clientFactory service.PrivacyClientFactory + baseURL string +} + +func NewOpenAIReferralClient(factory service.PrivacyClientFactory) service.OpenAIReferralClient { + return &openAIReferralClient{clientFactory: factory, baseURL: openAIReferralURL} +} + +func (c *openAIReferralClient) request(ctx context.Context, call service.OpenAIReferralCall) (*req.Request, error) { + client, err := c.clientFactory(call.ProxyURL) + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_CLIENT_ERROR", "failed to build referral client") + } + // Sending has no documented idempotency key. Override any factory retries. + return client.R().SetContext(ctx).SetHeaders(call.Headers).SetRetryCount(0), nil +} + +func (c *openAIReferralClient) QueryEligibility(ctx context.Context, call service.OpenAIReferralCall) (*service.OpenAIReferralEligibility, error) { + r, err := c.request(ctx, call) + if err != nil { + return nil, err + } + resp, err := r.SetQueryParams(map[string]string{"program_id": call.ProgramID, "entrypoint": openAIReferralEntrypoint}).Get(c.baseURL + "/eligibility") + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_QUERY_FAILED", "failed to query invitation eligibility") + } + if !resp.IsSuccessState() { + return nil, referralHTTPError(resp.StatusCode) + } + var result *service.OpenAIReferralEligibility + if err := json.Unmarshal(resp.Bytes(), &result); err != nil || result == nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_INVALID_RESPONSE", "invalid invitation eligibility response") + } + return result, nil +} + +func (c *openAIReferralClient) SendInvite(ctx context.Context, call service.OpenAIReferralCall, email string) error { + r, err := c.request(ctx, call) + if err != nil { + return err + } + resp, err := r.SetBody(map[string]any{ + "program_id": call.ProgramID, "entrypoint": openAIReferralEntrypoint, "emails": []string{email}, + }).Post(c.baseURL) + if err != nil { + return referralSendUnknown() + } + if !resp.IsSuccessState() { + return referralHTTPError(resp.StatusCode) + } + var payload struct { + Invites []json.RawMessage `json:"invites"` + } + if err := json.Unmarshal(resp.Bytes(), &payload); err != nil || len(payload.Invites) != 1 || string(payload.Invites[0]) == "null" { + return referralSendUnknown() + } + return nil +} + +func referralSendUnknown() error { + return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_SEND_UNKNOWN", "invitation outcome is unknown; check the invitation status in Codex before sending again") +} + +// Upstream bodies can contain personal data or credentials. Return only stable +// error codes distinguishing validation failures, duplicates and limits. +func referralHTTPError(status int) error { + switch status { + case http.StatusBadRequest, http.StatusUnprocessableEntity: + return infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_REJECTED", "the invitation request was rejected; check the email and eligibility") + case http.StatusUnauthorized: + return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_AUTH_ERROR", "upstream authentication failed; refresh the account credentials") + case http.StatusForbidden: + return infraerrors.New(http.StatusForbidden, "OPENAI_REFERRAL_FORBIDDEN", "this account is not eligible for invitations") + case http.StatusConflict: + return infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_ALREADY_EXISTS", "an invitation already exists for this recipient") + case http.StatusTooManyRequests: + return infraerrors.New(http.StatusTooManyRequests, "OPENAI_REFERRAL_RATE_LIMITED", "invitation limit reached; try again later") + default: + return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_UPSTREAM_ERROR", "invitation service is unavailable") + } +} diff --git a/backend/internal/repository/openai_referral_client_test.go b/backend/internal/repository/openai_referral_client_test.go new file mode 100644 index 0000000000..1143e37e23 --- /dev/null +++ b/backend/internal/repository/openai_referral_client_test.go @@ -0,0 +1,140 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/imroc/req/v3" + "github.com/stretchr/testify/require" +) + +func referralTestClient(t *testing.T, handler http.HandlerFunc) *openAIReferralClient { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + adapter := NewOpenAIReferralClient(func(proxy string) (*req.Client, error) { + require.Equal(t, "test-proxy", proxy) + // A shared factory may have retries; the adapter must override them. + return req.C().SetCommonRetryCount(2).SetCommonRetryCondition(func(_ *req.Response, _ error) bool { return true }), nil + }) + client, ok := adapter.(*openAIReferralClient) + require.True(t, ok) + client.baseURL = srv.URL + "/backend-api/referrals/invite" + return client +} + +func referralTestCall() service.OpenAIReferralCall { + return service.OpenAIReferralCall{ProxyURL: "test-proxy", ProgramID: "codex_referral_consumer", + Headers: map[string]string{"Authorization": "Bearer test-token", "ChatGPT-Account-ID": "workspace-test"}} +} + +func TestOpenAIReferralClientProtocol(t *testing.T) { + var gets, posts atomic.Int32 + client := referralTestClient(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + require.Equal(t, "workspace-test", r.Header.Get("ChatGPT-Account-ID")) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + gets.Add(1) + require.Equal(t, "/backend-api/referrals/invite/eligibility", r.URL.Path) + require.Equal(t, "codex_referral_consumer", r.URL.Query().Get("program_id")) + require.Equal(t, "persistent", r.URL.Query().Get("entrypoint")) + _, _ = w.Write([]byte(`{"should_show":true,"remaining_send_capacity":8,"remaining_reward_capacity":3,"grants":[{"grant_type":"rate_limit_reset_credit","amount":1,"recipient":"referrer"}],"rules":["Offer rule"]}`)) + return + } + posts.Add(1) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/backend-api/referrals/invite", r.URL.Path) + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, map[string]any{"program_id": "codex_referral_consumer", "entrypoint": "persistent", "emails": []any{"friend@example.com"}}, body) + _, _ = w.Write([]byte(`{"invites":[{"referral_id":"test-invite"}]}`)) + }) + result, err := client.QueryEligibility(context.Background(), referralTestCall()) + require.NoError(t, err) + require.True(t, result.ShouldShow) + require.Equal(t, 8, *result.RemainingSendCapacity) + require.Equal(t, 3, *result.RemainingRewardCapacity) + require.Equal(t, "rate_limit_reset_credit", result.Grants[0].GrantType) + require.Equal(t, []string{"Offer rule"}, result.Rules) + require.NoError(t, client.SendInvite(context.Background(), referralTestCall(), "friend@example.com")) + require.EqualValues(t, 1, gets.Load()) + require.EqualValues(t, 1, posts.Load()) +} + +func TestOpenAIReferralClientSanitizesErrorsWithoutRetry(t *testing.T) { + for _, tc := range []struct { + status int + reason string + }{ + {400, "OPENAI_REFERRAL_REJECTED"}, {422, "OPENAI_REFERRAL_REJECTED"}, + {401, "OPENAI_REFERRAL_AUTH_ERROR"}, {403, "OPENAI_REFERRAL_FORBIDDEN"}, + {409, "OPENAI_REFERRAL_ALREADY_EXISTS"}, {429, "OPENAI_REFERRAL_RATE_LIMITED"}, + {500, "OPENAI_REFERRAL_UPSTREAM_ERROR"}, + } { + t.Run(http.StatusText(tc.status), func(t *testing.T) { + var requests atomic.Int32 + client := referralTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(`{"detail":"secret-token someone@example.com"}`)) + }) + _, queryErr := client.QueryEligibility(context.Background(), referralTestCall()) + sendErr := client.SendInvite(context.Background(), referralTestCall(), "friend@example.com") + for _, err := range []error{queryErr, sendErr} { + require.Equal(t, tc.reason, infraerrors.Reason(err)) + require.NotContains(t, err.Error(), "secret-token") + require.NotContains(t, err.Error(), "someone@example.com") + } + require.EqualValues(t, 2, requests.Load(), "one query and one send, no retries") + }) + } +} + +func TestOpenAIReferralClientInvalidResponses(t *testing.T) { + for _, body := range []string{"null", "invalid json", `{"invites":[]}`, `{"invites":[null]}`, `{"invites":[{},{}]}`} { + t.Run(body, func(t *testing.T) { + var requests atomic.Int32 + client := referralTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + _, _ = w.Write([]byte(body)) + }) + err := client.SendInvite(context.Background(), referralTestCall(), "friend@example.com") + require.Equal(t, "OPENAI_REFERRAL_SEND_UNKNOWN", infraerrors.Reason(err)) + require.EqualValues(t, 1, requests.Load()) + if body == "null" || body == "invalid json" { + _, err := client.QueryEligibility(context.Background(), referralTestCall()) + require.Equal(t, "OPENAI_REFERRAL_INVALID_RESPONSE", infraerrors.Reason(err)) + } + }) + } +} + +func TestOpenAIReferralClientLostResponseIsUnknownWithoutRetry(t *testing.T) { + var requests atomic.Int32 + client := referralTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + hijacker, ok := w.(http.Hijacker) + require.True(t, ok) + conn, _, err := hijacker.Hijack() + require.NoError(t, err) + _ = conn.Close() // Upstream received the request but its response was lost. + }) + err := client.SendInvite(context.Background(), referralTestCall(), "friend@example.com") + require.Equal(t, "OPENAI_REFERRAL_SEND_UNKNOWN", infraerrors.Reason(err)) + require.EqualValues(t, 1, requests.Load()) +} + +func TestOpenAIReferralClientFactoryErrorIsSanitized(t *testing.T) { + client := NewOpenAIReferralClient(func(string) (*req.Client, error) { return nil, errors.New("secret proxy credentials") }) + err := client.SendInvite(context.Background(), referralTestCall(), "friend@example.com") + require.Equal(t, "OPENAI_REFERRAL_CLIENT_ERROR", infraerrors.Reason(err)) + require.NotContains(t, err.Error(), "secret") +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 4e2b0c5f85..f56fee1a58 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -162,6 +162,7 @@ var ProviderSet = wire.NewSet( NewClaudeOAuthClient, NewHTTPUpstream, NewOpenAIOAuthClient, + NewOpenAIReferralClient, NewGrokOAuthClient, NewGeminiOAuthClient, NewGeminiCliCodeAssistClient, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index b44ed4c542..ababc06d50 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -454,6 +454,8 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota) openai.POST("/accounts/:id/quota/refresh", h.Admin.OpenAIOAuth.RefreshQuota) openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota) + openai.POST("/accounts/:id/referrals/refresh", h.Admin.OpenAIOAuth.RefreshReferrals) + openai.POST("/accounts/:id/referrals/invite", h.Admin.OpenAIOAuth.SendReferralInvite) } } diff --git a/backend/internal/service/account_usage_service_spark_shadow_test.go b/backend/internal/service/account_usage_service_spark_shadow_test.go index 8aa131f03a..442ac6b4b0 100644 --- a/backend/internal/service/account_usage_service_spark_shadow_test.go +++ b/backend/internal/service/account_usage_service_spark_shadow_test.go @@ -117,7 +117,7 @@ func TestGetOpenAIUsage_SparkShadow_WritesExtraAndReturnsNonEmptyWindows(t *test })) defer srv.Close() - quotaService := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + quotaService := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) svc := &AccountUsageService{ accountRepo: repo, openAIQuotaService: quotaService, diff --git a/backend/internal/service/gemini_sse_comment_compat_test.go b/backend/internal/service/gemini_sse_comment_compat_test.go index 360f51ad38..c53e18ca0c 100644 --- a/backend/internal/service/gemini_sse_comment_compat_test.go +++ b/backend/internal/service/gemini_sse_comment_compat_test.go @@ -81,7 +81,11 @@ func runAntigravityGeminiStreamWithIdle(t *testing.T, userAgent string, idle tim } func TestAntigravityGeminiStreamKeepsCommentKeepaliveForOrdinaryClients(t *testing.T) { - out := runAntigravityGeminiStreamWithIdle(t, "curl/8.7.1", 1200*time.Millisecond) + // The keepalive interval is configured in whole seconds. Leave enough idle + // time for the first data event to be processed before the ticker fires; + // otherwise a busy CI runner can consume the first tick too early and close + // the stream before any heartbeat is written. + out := runAntigravityGeminiStreamWithIdle(t, "curl/8.7.1", 2200*time.Millisecond) require.Contains(t, out, ":\n\n", "ordinary clients should still get the idle keepalive") require.Contains(t, out, `"text":"partial"`) } diff --git a/backend/internal/service/openai_quota_credits_test.go b/backend/internal/service/openai_quota_credits_test.go new file mode 100644 index 0000000000..37f2a613b7 --- /dev/null +++ b/backend/internal/service/openai_quota_credits_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQueryUsageCodexCredits(t *testing.T) { + for _, tc := range []struct { + name string + body string + want string + }{ + {"decimal balance", `{"credits":{"has_credits":true,"unlimited":false,"balance":"12345678901234567890.0123"}}`, `{"has_credits":true,"unlimited":false,"balance":"12345678901234567890.0123"}`}, + {"zero", `{"credits":{"has_credits":false,"unlimited":false,"balance":"0"}}`, `{"has_credits":false,"unlimited":false,"balance":"0"}`}, + {"unlimited", `{"credits":{"has_credits":false,"unlimited":true,"balance":null}}`, `{"has_credits":false,"unlimited":true,"balance":null}`}, + {"hidden balance", `{"credits":{"has_credits":true,"unlimited":false}}`, `{"has_credits":true,"unlimited":false,"balance":null}`}, + {"absent", `{}`, `null`}, + {"null", `{"credits":null}`, `null`}, + } { + t.Run(tc.name, func(t *testing.T) { + account := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Credentials: map[string]any{"chatgpt_account_id": "test-workspace"}} + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: account}} + tokens := &stubQuotaTokenCache{tokens: map[string]string{OpenAITokenCacheKey(account): "test-token"}} + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + require.Equal(t, "test-workspace", r.Header.Get("ChatGPT-Account-ID")) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/backend-api/wham/usage" { + _, _ = w.Write([]byte(tc.body)) + return + } + // Reset-card details may be unavailable without losing points. + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + svc := NewOpenAIQuotaService(repo, nil, NewOpenAITokenProvider(repo, tokens, nil), newQuotaRedirectingFactory(srv), nil) + usage, err := svc.QueryUsage(context.Background(), 100) + require.NoError(t, err) + require.Positive(t, usage.FetchedAt) + encoded, err := json.Marshal(usage.Credits) + require.NoError(t, err) + require.JSONEq(t, tc.want, string(encoded)) + require.Equal(t, []string{"/backend-api/wham/usage", "/backend-api/wham/rate-limit-reset-credits"}, paths) + require.Zero(t, repo.extraUpdateCalls, "read-only query must not write account state") + }) + } +} + +func TestCacheCodexCreditsSnapshot(t *testing.T) { + ctx := context.Background() + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + balance := "1200.50" + usage := &OpenAIQuotaUsage{FetchedAt: 123, Credits: &OpenAICredits{HasCredits: true, Balance: &balance}} + + // No reset-card details are required to persist the balance on the queried row. + require.NoError(t, svc.CacheCreditsSnapshot(ctx, 200, usage)) + encoded, err := json.Marshal(repo.extraUpdates[200][openaiQuotaCreditsKey]) + require.NoError(t, err) + require.JSONEq(t, `{"credits":{"has_credits":true,"unlimited":false,"balance":"1200.50"},"fetched_at":123}`, string(encoded)) + require.NotContains(t, repo.extraUpdates, int64(100), "a shadow row must not overwrite its parent") + require.NotContains(t, repo.extraUpdates[200], openaiQuotaResetCreditsKey) + + // Missing credits from a successful read invalidate the previous balance. + require.NoError(t, svc.CacheCreditsSnapshot(ctx, 200, &OpenAIQuotaUsage{FetchedAt: 456})) + encoded, err = json.Marshal(repo.extraUpdates[200][openaiQuotaCreditsKey]) + require.NoError(t, err) + require.JSONEq(t, `{"credits":null,"fetched_at":456}`, string(encoded)) + + require.Error(t, svc.CacheCreditsSnapshot(ctx, 200, nil)) + require.Equal(t, 2, repo.extraUpdateCalls) + repo.extraUpdateErr = errors.New("database unavailable") + require.ErrorContains(t, svc.CacheCreditsSnapshot(ctx, 200, usage), "database unavailable") +} diff --git a/backend/internal/service/openai_quota_reset_credits_test.go b/backend/internal/service/openai_quota_reset_credits_test.go index 2deb01ed89..02edef3e4b 100644 --- a/backend/internal/service/openai_quota_reset_credits_test.go +++ b/backend/internal/service/openai_quota_reset_credits_test.go @@ -175,7 +175,7 @@ func TestQueryUsageResetCreditCountPrecedence(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(context.Background(), 100) require.NoError(t, err) require.NotNil(t, usage) diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index b05241b222..7f24c1d153 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -35,6 +35,7 @@ const ( openaiQuotaSecFetchMode = "no-cors" openaiQuotaSecFetchDest = "empty" openaiQuotaResetCreditsKey = "codex_reset_credit_snapshot" + openaiQuotaCreditsKey = "codex_credits_snapshot" ) // OpenAIRateLimitWindow describes a single rate-limit window returned by @@ -75,6 +76,21 @@ type OpenAIRateLimitResetCredits struct { Credits []OpenAIRateLimitResetCreditDetail `json:"credits,omitempty"` } +// OpenAICredits is the spendable Codex credit balance from /wham/usage. +// It is separate from reset credits. Upstream represents the balance as a +// nullable decimal string; keep that representation to preserve precision. +// Source: Codex 41ece455b7fa, codex-backend-openapi-models/src/models/credit_status_details.rs. +type OpenAICredits struct { + HasCredits bool `json:"has_credits"` + Unlimited bool `json:"unlimited"` + Balance *string `json:"balance"` +} + +type openAICreditsSnapshot struct { + Credits *OpenAICredits `json:"credits"` + FetchedAt int64 `json:"fetched_at"` +} + // OpenAIQuotaUsage is the typed projection of /wham/usage we expose to the UI. // Fields not relevant to the quota card are intentionally omitted to keep the // surface narrow; full upstream payload preservation is unnecessary. @@ -86,6 +102,7 @@ type OpenAIQuotaUsage struct { RateLimit *OpenAIRateLimit `json:"rate_limit,omitempty"` AdditionalRateLimits []OpenAIAdditionalRateLimit `json:"additional_rate_limits,omitempty"` RateLimitResetCredits *OpenAIRateLimitResetCredits `json:"rate_limit_reset_credits,omitempty"` + Credits *OpenAICredits `json:"credits,omitempty"` FetchedAt int64 `json:"fetched_at"` autoResetCandidates []openAIAutoResetCreditCandidate } @@ -119,6 +136,7 @@ type OpenAIQuotaService struct { proxyRepo ProxyRepository tokenProvider *OpenAITokenProvider privacyClientFactory PrivacyClientFactory + referralClient OpenAIReferralClient agentIdentityTaskMu sync.Mutex agentIdentityWS agentIdentityWSConnectionInvalidator } @@ -131,12 +149,14 @@ func NewOpenAIQuotaService( proxyRepo ProxyRepository, tokenProvider *OpenAITokenProvider, privacyClientFactory PrivacyClientFactory, + referralClient OpenAIReferralClient, ) *OpenAIQuotaService { return &OpenAIQuotaService{ accountRepo: accountRepo, proxyRepo: proxyRepo, tokenProvider: tokenProvider, privacyClientFactory: privacyClientFactory, + referralClient: referralClient, } } @@ -228,16 +248,36 @@ func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, acco return s.cacheResetCreditsSnapshot(ctx, accountID, credits, nil) } +// CacheCreditsSnapshot stores the queried row's display snapshot independently +// of reset-credit expiration details. A successful read with absent credits +// replaces the previous balance with unknown, never with a fabricated zero. +func (s *OpenAIQuotaService) CacheCreditsSnapshot(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage) error { + if usage == nil { + return infraerrors.New(http.StatusBadGateway, "OPENAI_QUOTA_EMPTY_USAGE", "openai quota query returned an empty result") + } + if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{ + openaiQuotaCreditsKey: openAICreditsSnapshot{Credits: usage.Credits, FetchedAt: usage.FetchedAt}, + }); err != nil { + return infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_CACHE_WRITE_FAILED", "failed to cache Codex credits").WithCause(err) + } + return nil +} + // CachePostResetSnapshot persists the credits and usage windows observed after a reset. func (s *OpenAIQuotaService) CachePostResetSnapshot(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage) error { if usage == nil { return s.cacheResetCreditsSnapshot(ctx, accountID, nil, nil) } + updates := buildOpenAIAutoResetUsageUpdates(usage, time.Now()) + if updates == nil { + updates = make(map[string]any) + } + updates[openaiQuotaCreditsKey] = openAICreditsSnapshot{Credits: usage.Credits, FetchedAt: usage.FetchedAt} return s.cacheResetCreditsSnapshot( ctx, accountID, usage.RateLimitResetCredits, - buildOpenAIAutoResetUsageUpdates(usage, time.Now()), + updates, ) } diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index b1f636d416..8bda444bf9 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -208,7 +208,7 @@ func TestResetCreditTargetedSendsStableCreditAndRedeemIDs(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) result, err := svc.ResetCreditTargeted(context.Background(), account.ID, "credit-123", "redeem-456") require.NoError(t, err) require.Equal(t, "ok", result.Code) @@ -261,7 +261,7 @@ func TestResetCreditAgentIdentityUsesAssertionAndRecoversInvalidTaskOnce(t *test t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase }) invalidator := &agentIdentityWSInvalidationRecorder{} - svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv), nil) svc.agentIdentityWS = invalidator result, err := svc.ResetCredit(context.Background(), account.ID) @@ -323,7 +323,7 @@ func TestResetCreditAgentIdentityReusesConcurrentlyRecoveredTask(t *testing.T) { openAIAgentIdentityAuthAPIBaseURL = srv.URL t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase }) - svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv), nil) result, err := svc.ResetCredit(context.Background(), account.ID) require.NoError(t, err) require.Equal(t, "ok", result.Code) @@ -376,7 +376,7 @@ func TestPrepareUpstreamCallShadowResolve(t *testing.T) { // privacyClientFactory 可以是任意合法工厂;prepareUpstreamCall 在返回前不调用它 svc := NewOpenAIQuotaService(repo, nil, tokenProvider, func(_ string) (*req.Client, error) { return req.C(), nil - }) + }, nil) _, chatGPTAccountID, _, _, err := svc.prepareUpstreamCall(ctx, 200) require.NoError(t, err, "shadow resolve should succeed; got error: %v", err) @@ -414,7 +414,7 @@ func TestQueryUsageAgentIdentityUsesAssertionWithoutOAuthToken(t *testing.T) { _, _ = w.Write([]byte(`{"plan_type":"pro","rate_limit":{"allowed":true}}`)) })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(context.Background(), account.ID) require.NoError(t, err) require.NotNil(t, usage) @@ -468,7 +468,7 @@ func TestQueryUsageAgentIdentityRecoversInvalidTaskOnce(t *testing.T) { t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase }) invalidator := &agentIdentityWSInvalidationRecorder{} - svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv), nil) svc.agentIdentityWS = invalidator usage, err := svc.QueryUsage(context.Background(), account.ID) require.NoError(t, err) @@ -564,7 +564,7 @@ func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(ctx, 100) require.NoError(t, err) require.NotNil(t, usage) @@ -625,7 +625,7 @@ func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(ctx, 100) require.NoError(t, err) require.NotNil(t, usage) @@ -700,7 +700,10 @@ func TestCachePostResetSnapshot(t *testing.T) { repo := &stubQuotaAccountRepo{} svc := &OpenAIQuotaService{accountRepo: repo} credits := &OpenAIRateLimitResetCredits{AvailableCount: 0} + balance := "1200.50" usage := &OpenAIQuotaUsage{ + Credits: &OpenAICredits{HasCredits: true, Balance: &balance}, + FetchedAt: 123, RateLimitResetCredits: credits, RateLimit: &OpenAIRateLimit{ PrimaryWindow: &OpenAIRateLimitWindow{ @@ -715,6 +718,7 @@ func TestCachePostResetSnapshot(t *testing.T) { require.NoError(t, svc.CachePostResetSnapshot(context.Background(), 100, usage)) require.Equal(t, 1, repo.extraUpdateCalls) require.Equal(t, credits, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) + require.Equal(t, openAICreditsSnapshot{Credits: usage.Credits, FetchedAt: 123}, repo.extraUpdates[100][openaiQuotaCreditsKey]) require.Equal(t, 0.0, repo.extraUpdates[100]["codex_5h_used_percent"]) require.Equal(t, 0.0, repo.extraUpdates[100]["codex_7d_used_percent"]) } @@ -770,7 +774,7 @@ func TestQueryUsageShadowResolve_EndToEnd(t *testing.T) { })) defer srv.Close() - svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv), nil) usage, err := svc.QueryUsage(ctx, 200) require.NoError(t, err) require.NotNil(t, usage) diff --git a/backend/internal/service/openai_referral_client.go b/backend/internal/service/openai_referral_client.go new file mode 100644 index 0000000000..1136ccf098 --- /dev/null +++ b/backend/internal/service/openai_referral_client.go @@ -0,0 +1,19 @@ +package service + +import "context" + +// OpenAIReferralCall carries the account's refreshed authentication and proxy +// settings to the transport adapter. Headers must never be logged or persisted. +type OpenAIReferralCall struct { + ProxyURL string + Headers map[string]string + ProgramID string +} + +// OpenAIReferralClient isolates invitation transport from eligibility policy. +// QueryEligibility returns a non-nil result on success. SendInvite must not +// retry and returns nil only when the upstream confirms one invitation. +type OpenAIReferralClient interface { + QueryEligibility(context.Context, OpenAIReferralCall) (*OpenAIReferralEligibility, error) + SendInvite(context.Context, OpenAIReferralCall, string) error +} diff --git a/backend/internal/service/openai_referral_service.go b/backend/internal/service/openai_referral_service.go new file mode 100644 index 0000000000..37cc843687 --- /dev/null +++ b/backend/internal/service/openai_referral_service.go @@ -0,0 +1,181 @@ +package service + +import ( + "context" + "net/http" + "net/mail" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + openAIReferralSnapshotKey = "codex_referral_snapshot" + openAIReferralConsumer = "codex_referral_consumer" + openAIReferralWorkspace = "codex_referral_workspace" +) + +type OpenAIReferralGrant struct { + GrantType string `json:"grant_type"` + Recipient string `json:"recipient"` + Amount float64 `json:"amount"` +} + +type OpenAIReferralEligibility struct { + ShouldShow bool `json:"should_show"` + RemainingSendCapacity *int `json:"remaining_send_capacity"` + RemainingRewardCapacity *int `json:"remaining_reward_capacity"` + RequiresExplicitConfirmation *bool `json:"requires_explicit_confirmation"` + OfferID string `json:"offer_id,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Rules []string `json:"rules,omitempty"` + Grants []OpenAIReferralGrant `json:"grants,omitempty"` + ProgramID string `json:"program_id"` + AvailableInvites *int `json:"available_invites"` + FetchedAt int64 `json:"fetched_at"` +} + +// referralCapacity follows the desktop's send/reward capacity constraints. +// The desktop additionally caps one batch at five; this API sends one email. +func referralCapacity(e *OpenAIReferralEligibility) *int { + if !e.ShouldShow { + zero := 0 + return &zero + } + if e.RemainingSendCapacity == nil { + return nil + } + count := max(0, *e.RemainingSendCapacity) + if len(e.Grants) > 0 || (e.OfferID != "" && e.OfferID != "none") { + if e.RemainingRewardCapacity == nil { + return nil + } + count = min(count, max(0, *e.RemainingRewardCapacity)) + } + return &count +} + +func (s *OpenAIQuotaService) referralAccount(ctx context.Context, id int64, sending bool) (*Account, error) { + if s == nil || s.accountRepo == nil { + return nil, infraerrors.New(http.StatusServiceUnavailable, "OPENAI_REFERRAL_NOT_CONFIGURED", "referral service is unavailable") + } + a, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if a == nil { + return nil, ErrAccountNotFound + } + if a.Platform != PlatformOpenAI || a.Type != AccountTypeOAuth { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_INVALID_ACCOUNT", "referrals require an OpenAI OAuth account") + } + if a.IsShadow() { + if sending { + return nil, infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_SHADOW_ACCOUNT", "send invitations from the parent account") + } + return resolveCredentialAccount(ctx, s.accountRepo, a) + } + return a, nil +} + +func referralProgram(a *Account) string { + if strings.EqualFold(a.GetCredential("account_type"), "workspace") { + return openAIReferralWorkspace + } + switch strings.ToLower(strings.TrimSpace(a.GetCredential("plan_type"))) { + case "team", "business", "self_serve_business_prolite", "self_serve_business_usage_based", "free_workspace", "enterprise", "enterprise_cbp_usage_based", "enterprise_cbp_automation", "edu", "education": + return openAIReferralWorkspace + default: + return openAIReferralConsumer + } +} + +func (s *OpenAIQuotaService) referralCall(ctx context.Context, id int64, program string) (OpenAIReferralCall, error) { + if s.referralClient == nil { + return OpenAIReferralCall{}, infraerrors.New(http.StatusServiceUnavailable, "OPENAI_REFERRAL_NOT_CONFIGURED", "referral service is unavailable") + } + token, accountID, proxy, fedRAMP, err := s.prepareUpstreamCall(ctx, id) + if err != nil { + return OpenAIReferralCall{}, err + } + headers, _, err := s.buildCodexQuotaHeaders(ctx, id, token, accountID, fedRAMP) + if err != nil { + return OpenAIReferralCall{}, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_AUTH_ERROR", "failed to authenticate referral request") + } + return OpenAIReferralCall{ProxyURL: proxy, Headers: headers, ProgramID: program}, nil +} + +func (s *OpenAIQuotaService) QueryReferralEligibility(ctx context.Context, id int64) (*OpenAIReferralEligibility, error) { + account, err := s.referralAccount(ctx, id, false) + if err != nil { + return nil, err + } + callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout) + defer cancel() + program := referralProgram(account) + call, err := s.referralCall(callCtx, id, program) + if err != nil { + return nil, err + } + result, err := s.referralClient.QueryEligibility(callCtx, call) + if err != nil { + return nil, err + } + result.ProgramID = program + result.AvailableInvites = referralCapacity(result) + result.FetchedAt = time.Now().Unix() + return result, nil +} + +func (s *OpenAIQuotaService) CacheReferralSnapshot(ctx context.Context, id int64, eligibility *OpenAIReferralEligibility) error { + return s.accountRepo.UpdateExtra(ctx, id, map[string]any{openAIReferralSnapshotKey: eligibility}) +} + +type OpenAIReferralSendRequest struct { + Email string `json:"email"` + ProgramID string `json:"program_id"` + Confirmed bool `json:"confirmed"` +} + +type OpenAIReferralSendResult struct { + Email string `json:"email"` + Sent bool `json:"sent"` +} + +func (s *OpenAIQuotaService) SendReferralInvite(ctx context.Context, id int64, input OpenAIReferralSendRequest) (*OpenAIReferralSendResult, error) { + email := strings.TrimSpace(input.Email) + address, err := mail.ParseAddress(email) + if err != nil || address.Address != email || len(email) > 254 || strings.ContainsAny(email, "\r\n") { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_INVALID_EMAIL", "enter one valid email address") + } + account, err := s.referralAccount(ctx, id, true) + if err != nil { + return nil, err + } + if input.ProgramID != referralProgram(account) { + return nil, infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_PROGRAM_CHANGED", "refresh invitation eligibility before sending") + } + // Recheck the server's current eligibility rather than trusting a UI cache. + eligibility, err := s.QueryReferralEligibility(ctx, id) + if err != nil { + return nil, err + } + if !eligibility.ShouldShow || eligibility.AvailableInvites == nil || *eligibility.AvailableInvites <= 0 { + return nil, infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_UNAVAILABLE", "no invitations are currently available") + } + if (eligibility.RequiresExplicitConfirmation == nil || *eligibility.RequiresExplicitConfirmation) && !input.Confirmed { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_CONFIRMATION_REQUIRED", "confirm the recipient's consent before sending") + } + callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout) + defer cancel() + call, err := s.referralCall(callCtx, id, eligibility.ProgramID) + if err != nil { + return nil, err + } + if err := s.referralClient.SendInvite(callCtx, call, email); err != nil { + return nil, err + } + return &OpenAIReferralSendResult{Email: email, Sent: true}, nil +} diff --git a/backend/internal/service/openai_referral_service_test.go b/backend/internal/service/openai_referral_service_test.go new file mode 100644 index 0000000000..579a01a87b --- /dev/null +++ b/backend/internal/service/openai_referral_service_test.go @@ -0,0 +1,137 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/imroc/req/v3" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +type referralClientStub struct { + eligibility *OpenAIReferralEligibility + queryErr, sendErr error + calls []OpenAIReferralCall + emails []string +} + +func (s *referralClientStub) QueryEligibility(_ context.Context, call OpenAIReferralCall) (*OpenAIReferralEligibility, error) { + s.calls = append(s.calls, call) + return s.eligibility, s.queryErr +} +func (s *referralClientStub) SendInvite(_ context.Context, call OpenAIReferralCall, email string) error { + s.calls = append(s.calls, call) + s.emails = append(s.emails, email) + return s.sendErr +} + +func referralTestService(t *testing.T, plan string, client OpenAIReferralClient) (*OpenAIQuotaService, *stubQuotaAccountRepo) { + t.Helper() + a := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Credentials: map[string]any{"chatgpt_account_id": "workspace-test", "plan_type": plan}} + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: a}} + tokens := &stubQuotaTokenCache{tokens: map[string]string{OpenAITokenCacheKey(a): "test-token"}} + factory := func(string) (*req.Client, error) { + t.Fatal("referral service must use the business interface, not the HTTP factory") + return nil, nil + } + return NewOpenAIQuotaService(repo, nil, NewOpenAITokenProvider(repo, tokens, nil), factory, client), repo +} + +func TestOpenAIReferralSend(t *testing.T) { + for _, tc := range []struct{ plan, program string }{ + {"plus", openAIReferralConsumer}, {"team", openAIReferralWorkspace}, + {"self_serve_business_usage_based", openAIReferralWorkspace}, + } { + t.Run(tc.plan, func(t *testing.T) { + send, reward := 8, 3 + client := &referralClientStub{eligibility: &OpenAIReferralEligibility{ + ShouldShow: true, RemainingSendCapacity: &send, RemainingRewardCapacity: &reward, + Grants: []OpenAIReferralGrant{{GrantType: "rate_limit_reset_credit", Amount: 1, Recipient: "referrer"}}, + Rules: []string{"Offer rule"}, + }} + svc, repo := referralTestService(t, tc.plan, client) + eligibility, err := svc.QueryReferralEligibility(context.Background(), 100) + require.NoError(t, err) + require.Equal(t, 3, *eligibility.AvailableInvites) + require.Equal(t, []string{"Offer rule"}, eligibility.Rules) + require.Positive(t, eligibility.FetchedAt) + require.NoError(t, svc.CacheReferralSnapshot(context.Background(), 100, eligibility)) + require.Equal(t, eligibility, repo.extraUpdates[100][openAIReferralSnapshotKey]) + result, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{ + Email: " friend@example.com ", ProgramID: tc.program, Confirmed: true, + }) + require.NoError(t, err) + require.True(t, result.Sent) + require.Equal(t, "friend@example.com", result.Email) + require.Equal(t, []string{"friend@example.com"}, client.emails) + require.Len(t, client.calls, 3, "send must recheck eligibility") + for _, call := range client.calls { + require.Equal(t, tc.program, call.ProgramID) + headers := make(http.Header) + for key, value := range call.Headers { + headers.Set(key, value) + } + require.Equal(t, "Bearer test-token", headers.Get("Authorization")) + require.Equal(t, "workspace-test", headers.Get("ChatGPT-Account-ID")) + } + }) + } +} + +func TestOpenAIReferralSendGuards(t *testing.T) { + for _, tc := range []struct { + name, email, body, reason string + confirmed, shadow bool + }{ + {"invalid email", "bad-email", `{}`, "OPENAI_REFERRAL_INVALID_EMAIL", true, false}, + {"multiple emails", "a@example.com,b@example.com", `{}`, "OPENAI_REFERRAL_INVALID_EMAIL", true, false}, + {"display name", "User ", `{}`, "OPENAI_REFERRAL_INVALID_EMAIL", true, false}, + {"header injection", "a@example.com\r\nBcc: b@example.com", `{}`, "OPENAI_REFERRAL_INVALID_EMAIL", true, false}, + {"no consent", "a@example.com", `{"should_show":true,"remaining_send_capacity":2}`, "OPENAI_REFERRAL_CONFIRMATION_REQUIRED", false, false}, + {"ineligible", "a@example.com", `{"should_show":false,"remaining_send_capacity":2}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"exhausted", "a@example.com", `{"should_show":true,"remaining_send_capacity":0}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"unknown capacity", "a@example.com", `{"should_show":true}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"reward exhausted", "a@example.com", `{"should_show":true,"remaining_send_capacity":3,"offer_id":"credits_250","remaining_reward_capacity":0}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"unknown reward capacity", "a@example.com", `{"should_show":true,"remaining_send_capacity":3,"grants":[{}]}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"shadow", "a@example.com", `{}`, "OPENAI_REFERRAL_SHADOW_ACCOUNT", true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + client := &referralClientStub{} + require.NoError(t, json.Unmarshal([]byte(tc.body), &client.eligibility)) + svc, repo := referralTestService(t, "plus", client) + if tc.shadow { + parentID := int64(200) + repo.accounts[100].ParentAccountID = &parentID + } + _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: tc.email, ProgramID: openAIReferralConsumer, Confirmed: tc.confirmed}) + require.Equal(t, tc.reason, infraerrors.Reason(err)) + require.Empty(t, client.emails) + }) + } +} + +func TestOpenAIReferralSendStopsOnQueryError(t *testing.T) { + client := &referralClientStub{queryErr: errors.New("eligibility unavailable")} + svc, _ := referralTestService(t, "plus", client) + _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: "friend@example.com", ProgramID: openAIReferralConsumer, Confirmed: true}) + require.ErrorIs(t, err, client.queryErr) + require.Empty(t, client.emails) +} + +func TestOpenAIReferralSendPreservesUnknownOutcomeWithoutRetry(t *testing.T) { + count := 2 + client := &referralClientStub{ + eligibility: &OpenAIReferralEligibility{ShouldShow: true, RemainingSendCapacity: &count}, + sendErr: infraerrors.New(502, "OPENAI_REFERRAL_SEND_UNKNOWN", "outcome unknown"), + } + svc, _ := referralTestService(t, "plus", client) + _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: "friend@example.com", ProgramID: openAIReferralConsumer, Confirmed: true}) + require.ErrorIs(t, err, client.sendErr) + require.Len(t, client.emails, 1) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 3944ca5f07..2af85ff6d8 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -174,6 +174,13 @@ func ProvideOpenAITokenProvider( return p } +// ProvidePluginManager preserves account-directory wiring when regenerating Wire. +func ProvidePluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kvStore PluginKVStore, gateway *OpenAIGatewayService) *PluginManager { + manager := NewPluginManager(repo, encryptor, cfg, hostInfo, kvStore) + manager.SetAccountDirectory(gateway) + return manager +} + // ProvideOpenAIQuotaService wires the OpenAI quota query/reset service. // It depends on the OpenAI token provider for refreshed access tokens and the // privacy client factory for the impersonated upstream HTTP client. @@ -182,9 +189,10 @@ func ProvideOpenAIQuotaService( proxyRepo ProxyRepository, tokenProvider *OpenAITokenProvider, privacyClientFactory PrivacyClientFactory, + referralClient OpenAIReferralClient, openAIGatewayService *OpenAIGatewayService, ) *OpenAIQuotaService { - service := NewOpenAIQuotaService(accountRepo, proxyRepo, tokenProvider, privacyClientFactory) + service := NewOpenAIQuotaService(accountRepo, proxyRepo, tokenProvider, privacyClientFactory, referralClient) service.agentIdentityWS = openAIGatewayService return service } @@ -924,7 +932,7 @@ var ProviderSet = wire.NewSet( NewTotpService, NewErrorPassthroughService, NewTLSFingerprintProfileService, - NewPluginManager, + ProvidePluginManager, NewDigestSessionStore, ProvideIdempotencyCoordinator, ProvideSystemOperationLockService, diff --git a/docs/codex-credits.md b/docs/codex-credits.md new file mode 100644 index 0000000000..2327becee9 --- /dev/null +++ b/docs/codex-credits.md @@ -0,0 +1,67 @@ +# Codex 点数查询 + +Codex 源码核对版本:`41ece455b7fa7166f4fc38522952afdaa2604e18`。 + +- `codex-rs/backend-client/src/client/rate_limit_resets.rs`:`get_rate_limit_status()` 使用 `GET /wham/usage`;ChatGPT 完整地址为 `https://chatgpt.com/backend-api/wham/usage`。Codex API 路由模式使用 `/api/codex/usage`。 +- `codex-rs/backend-client/src/client.rs`:`headers()` 添加身份认证和 `ChatGPT-Account-Id`,`map_credits()` 读取点数对象。 +- `codex-rs/codex-backend-openapi-models/src/models/credit_status_details.rs`:`has_credits`、`unlimited` 为布尔值,`balance` 为可缺省、可空的字符串。 +- `codex-rs/codex-api/src/rate_limits.rs`:推理响应也可以通过 `x-codex-credits-has-credits`、`x-codex-credits-unlimited` 和 `x-codex-credits-balance` 响应头携带点数快照。 + +点数是 `credits`;重置卡是 `rate_limit_reset_credits`。两者独立,不能混用。点数余额按原始十进制字符串保留,不折算成 sub2api 用户余额或美元。 + +## sub2api 接入 + +复用现有 OAuth token 刷新、账号绑定及代理客户端。管理员账户用量区域点击「点数」或「次数」,通过 `POST /api/v1/admin/openai/accounts/:id/quota/refresh` 一次刷新两种数据。只读查询仍使用 `GET /api/v1/admin/openai/accounts/:id/quota`。 + +`credits` 随额度查询和重置后的查询结果返回。点数缓存保存在账户 `extra.codex_credits_snapshot`,包含 `credits` 和 Unix 秒时间戳 `fetched_at`,无需数据库迁移。`credits_cache_persisted` 单独报告点数缓存是否保存成功,不受重置卡到期明细缺失影响。余额按钮提示中显示查询时间,点击可刷新。 + +- 有限点数:显示余额字符串,保留小数。 +- `unlimited: true`:显示「无限」,优先于其他字段。 +- `has_credits: false`:显示 `0`。 +- 有点数但余额未公开:显示「可用」。 +- 缺失或空的 `credits`:显示 `—`,不当作零余额;成功刷新会清除旧余额快照。 +- 请求失败:保留最近一次查询结果并显示错误。 +- Spark 影子账号:使用既有母账号凭证解析逻辑查询,缓存保存在被查询行。 + +本接入只查询和展示点数,不触发购买或消费;原有重置操作仍只使用重置卡。 + +## 可邀请次数与邀请用户 + +邀请接口核对来源为本机桌面应用 `26.908.40834` 的 `app.asar/webview/assets/app-primary-44ec287874b7.js`,对应 `SIt`(资格查询)、`EIt`(发送)、`PIt`(邀请计划)、`FIt`(次数约束)。邀请功能不在本机 Rust CLI 源码中。 + +上游接口: + +```http +GET https://chatgpt.com/backend-api/referrals/invite/eligibility?program_id=codex_referral_consumer&entrypoint=persistent +POST https://chatgpt.com/backend-api/referrals/invite +Content-Type: application/json + +{"program_id":"codex_referral_consumer","entrypoint":"persistent","emails":["friend@example.com"]} +``` + +个人邀请使用 `codex_referral_consumer`,工作空间邀请使用 `codex_referral_workspace`。sub2api 优先使用已保存的 `account_type` 判断工作空间,否则根据 `plan_type` 选择。资格接口返回的 `should_show` 决定是否可发送;`remaining_send_capacity` 是剩余发送次数,有奖励时还受 `remaining_reward_capacity` 限制。保留空值表示未知,不硬编码历史活动的邀请上限。每次发送一个邮箱。 + +管理员入口:账户用量区域的「可邀请」查询按钮与「邀请用户」弹窗。支持以下接口: + +- `POST /api/v1/admin/openai/accounts/:id/referrals/refresh`:查询并缓存资格和次数到 `extra.codex_referral_snapshot`,包含查询时间。返回 `eligibility` 和 `cache_persisted`。 +- `POST /api/v1/admin/openai/accounts/:id/referrals/invite`:请求体为 `email`、`program_id`、`confirmed`。后端重新查询资格,验证计划、邮箱、次数及收件人同意状态,再发送邀请。 + +活动说明和资格规则显示上游的 `title`、`description` 和 `rules`。`requires_explicit_confirmation` 不为 `false` 时必须勾选收件人同意;此值不伪造为已同意。Spark 影子账号可查询母账号次数,但发送需从母账号操作。 + +发送成功后,后端以脱离请求取消的 8 秒上下文刷新次数,再使用独立的 3 秒上下文保存结果。回读耗尽期限仍可清空过期快照,并返回 `sent: true`、`refresh_failed: true`。两个展示快照字段不触发调度事件或调度桶重建。 + +发送请求不自动重试。公共 Axios 拦截器保留网络错误码;发送时断网、超时或取消均提示结果不确定,要求先在 Codex 核对邀请状态,并禁用再次发送,直到手动刷新资格。只读查询失败显示查询错误。接口错误不会向前端透传上游原始响应中的凭证或无关个人信息。 + +邀请业务通过 `service.OpenAIReferralClient` 接口调用仓储层 HTTP 适配器。Service 负责账号、计划、次数和同意校验;适配器负责代理客户端、请求协议、响应解析、错误脱敏与禁止重试,通过 Wire 注入。 + +### 自动回归检查 + +`make test-frontend` 的 CI 测试清单包含邀请组件、真实 Axios 拦截器与邀请组件联动测试、点数组件测试。后端单元测试覆盖回读期限耗尽后清理缓存、展示快照不写调度 outbox,以及邀请适配器协议、错误脱敏和断连不重试。 + +### 界面测试 + +以下截图使用真实 Vue 组件和模拟 API 响应,不包含真实账号凭证,也没有发送邮件。 + +![邀请弹窗与同意确认](screenshots/codex-referral-dialog.png) + +![模拟发送成功后刷新剩余次数](screenshots/codex-referral-success.png) diff --git a/docs/screenshots/codex-referral-dialog.png b/docs/screenshots/codex-referral-dialog.png new file mode 100644 index 0000000000..b0ec8a019d Binary files /dev/null and b/docs/screenshots/codex-referral-dialog.png differ diff --git a/docs/screenshots/codex-referral-success.png b/docs/screenshots/codex-referral-success.png new file mode 100644 index 0000000000..e443315e8b Binary files /dev/null and b/docs/screenshots/codex-referral-success.png differ diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index f0c70e9291..debcb6a67e 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -494,9 +494,9 @@ describe('API Client', () => { // --- 网络错误 --- describe('网络错误', () => { - it('网络错误返回 status 0 的错误', async () => { + it.each(['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT', undefined])('网络错误保留错误码 %s', async (code) => { const adapter = vi.fn().mockRejectedValue({ - code: 'ERR_NETWORK', + code, message: 'Network Error', config: { url: '/test' }, // 没有 response @@ -506,6 +506,7 @@ describe('API Client', () => { await expect(apiClient.get('/test')).rejects.toEqual( expect.objectContaining({ status: 0, + code: code || 'ERR_NETWORK', message: 'Network error. Please check your connection.', }) ) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index a816cebd15..349f4328f4 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -4,6 +4,7 @@ */ import { apiClient } from '../client' +import type { OpenAIReferralRefreshResult, OpenAIReferralSendResult } from '@/types/openaiReferrals' import type { Account, AccountListItem, @@ -918,9 +919,16 @@ export interface OpenAIQuotaUsage { rate_limit?: OpenAIRateLimit | null additional_rate_limits?: OpenAIAdditionalRateLimit[] rate_limit_reset_credits?: OpenAIRateLimitResetCredits | null + credits?: OpenAICredits | null fetched_at: number } +export interface OpenAICredits { + has_credits: boolean + unlimited: boolean + balance: string | null +} + export interface OpenAIQuotaResetCredit { id?: string reset_type?: string @@ -948,6 +956,7 @@ export interface OpenAIQuotaResetResult { /** Usage payload plus whether the reset-credit snapshot was persisted. */ export interface OpenAIQuotaRefreshResult extends OpenAIQuotaUsage { cache_persisted: boolean + credits_cache_persisted?: boolean } /** @@ -966,6 +975,23 @@ export async function refreshOpenAIQuota(id: number): Promise { + const { data } = await apiClient.post( + `/admin/openai/accounts/${id}/referrals/refresh` + ) + return data +} + +export async function sendOpenAIReferralInvite( + id: number, + input: { email: string; program_id: string; confirmed: boolean } +): Promise { + const { data } = await apiClient.post( + `/admin/openai/accounts/${id}/referrals/invite`, input, { timeout: 90_000 } + ) + return data +} + /** * Consume one rate-limit-reset credit for an OpenAI/Codex OAuth account. * diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 04d893d26f..064f8f83ee 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -268,6 +268,7 @@ apiClient.interceptors.response.use( // Network error return Promise.reject({ status: 0, + code: error.code || 'ERR_NETWORK', message: 'Network error. Please check your connection.' }) } diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index 6a3225b8bf..60a8465634 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -8,8 +8,8 @@ The 5h / 7d window bars are deliberately NOT rendered here — the local active-sampling display (UsageProgressBar in AccountUsageCell) already - owns that real estate. This cell is purely about the rate-limit reset - credit: query its count, consume one if needed. + owns that real estate. This cell queries Codex points and reset credits, + and lets the operator consume a reset credit if needed. -->
@@ -61,6 +61,23 @@ {{ t('admin.accounts.openaiQuotaReset.reset') }} + + + +
+ +
+ {{ t('admin.accounts.openaiQuotaReset.pointsCachePersistFailed') }}
(null) const resetWarning = ref(null) const showResetConfirm = ref(false) const showResetCreditDetails = ref(false) +const creditsCacheWarning = ref(false) + +const readCachedCredits = (account: Account) => { + const snapshot = account.extra?.codex_credits_snapshot + const credits = snapshot?.credits + if (!credits || typeof credits.has_credits !== 'boolean' || typeof credits.unlimited !== 'boolean') return null + if (credits.balance != null && typeof credits.balance !== 'string') return null + return { credits, fetched_at: snapshot.fetched_at } +} +const creditsData = ref(readCachedCredits(props.account)) +const creditsDisplay = computed(() => { + const credits = creditsData.value?.credits + if (!credits) return '—' + if (credits.unlimited) return t('admin.accounts.openaiQuotaReset.pointsUnlimited') + if (!credits.has_credits) return '0' + const balance = credits.balance?.trim() + // Keep the upstream decimal string intact, including fractional points. + if (balance && Number.isFinite(Number(balance)) && Number(balance) >= 0) return balance + return t('admin.accounts.openaiQuotaReset.pointsAvailable') +}) +const creditsButtonTitle = computed(() => { + const fetchedAt = creditsData.value?.fetched_at + const refresh = t('admin.accounts.openaiQuotaReset.pointsTooltip') + if (!fetchedAt || !Number.isFinite(fetchedAt)) return refresh + return `${refresh}\n${t('admin.accounts.openaiQuotaReset.pointsUpdatedAt', { + time: new Date(fetchedAt * 1000).toLocaleString() + })}` +}) + +const updateCredits = (usage: OpenAIQuotaUsage | null) => { + creditsData.value = usage?.credits ? { credits: usage.credits, fetched_at: usage.fetched_at } : null +} type AutoResetCreditState = NonNullable['codex_auto_reset_credit_state']> const validAutoResetStatuses = new Set(['checking', 'available', 'resetting', 'success', 'no_credit', 'failed']) @@ -383,14 +433,19 @@ const toggleResetCreditDetails = () => { } const handleQuery = async () => { - if (loading.value) return + if (loading.value || resetting.value) return + const accountID = props.account.id loading.value = true + creditsCacheWarning.value = false error.value = null resetMessage.value = null resetWarning.value = null showResetCreditDetails.value = false try { - const result = await refreshOpenAIQuota(props.account.id) + const result = await refreshOpenAIQuota(accountID) + if (props.account.id !== accountID) return + updateCredits(result) + creditsCacheWarning.value = result.credits_cache_persisted === false // The upstream read succeeded even when the snapshot write was rejected, so // the live count is always adopted. Only the persisted view is left alone, // which keeps the displayed expirations consistent with what is stored. @@ -401,9 +456,10 @@ const handleQuery = async () => { resetWarning.value = t('admin.accounts.openaiQuotaReset.refreshCachePersistFailed') } } catch (e) { + if (props.account.id !== accountID) return error.value = extractErrorMessage(e) } finally { - loading.value = false + if (props.account.id === accountID) loading.value = false } } @@ -424,11 +480,15 @@ const confirmReset = async () => { return } resetting.value = true + const accountID = props.account.id + creditsCacheWarning.value = false error.value = null resetMessage.value = null resetWarning.value = null try { - const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id) + const result: OpenAIQuotaResetResult = await resetOpenAIQuota(accountID) + if (props.account.id !== accountID) return + updateCredits(result.quota ?? null) showResetCreditDetails.value = false if (result.cache_refreshed && result.quota) { data.value = result.quota @@ -453,9 +513,10 @@ const confirmReset = async () => { }) } } catch (e) { + if (props.account.id !== accountID) return error.value = extractErrorMessage(e) } finally { - resetting.value = false + if (props.account.id === accountID) resetting.value = false } } @@ -465,6 +526,8 @@ watch( // Account row may be reused across paginated lists; reset local state. cachedData.value = readCachedResetCredits(props.account) data.value = cachedData.value + creditsData.value = readCachedCredits(props.account) + creditsCacheWarning.value = false error.value = null resetMessage.value = null resetWarning.value = null diff --git a/frontend/src/components/account/OpenAIReferralCell.vue b/frontend/src/components/account/OpenAIReferralCell.vue new file mode 100644 index 0000000000..65df69a57f --- /dev/null +++ b/frontend/src/components/account/OpenAIReferralCell.vue @@ -0,0 +1,225 @@ + + + diff --git a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts index 280725bc5b..53101d8f92 100644 --- a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts +++ b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts @@ -3,11 +3,13 @@ import { flushPromises, mount } from '@vue/test-utils' import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import type { Account } from '@/types' -import { refreshOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts' +import { refreshOpenAIQuota, resetOpenAIQuota, type OpenAIQuotaRefreshResult } from '@/api/admin/accounts' vi.mock('@/api/admin/accounts', () => ({ refreshOpenAIQuota: vi.fn(), resetOpenAIQuota: vi.fn(), + refreshOpenAIReferrals: vi.fn(), + sendOpenAIReferralInvite: vi.fn(), })) vi.mock('vue-i18n', async () => { @@ -64,6 +66,89 @@ beforeEach(() => { vi.mocked(resetOpenAIQuota).mockReset() }) +describe('OpenAIQuotaResetCell — Codex 点数', () => { + const points = (wrapper: ReturnType) => wrapper.get('[data-testid="codex-credits"]') + const balance = { has_credits: true, unlimited: false, balance: '12345678901234567890.0123' } + + it.each([ + { name: 'decimal precision', credits: balance, expected: balance.balance }, + { name: 'zero', credits: { has_credits: false, unlimited: false, balance: '0' }, expected: '0' }, + { name: 'unlimited takes precedence', credits: { has_credits: false, unlimited: true, balance: null }, expected: 'admin.accounts.openaiQuotaReset.pointsUnlimited' }, + { name: 'hidden balance', credits: { has_credits: true, unlimited: false, balance: null }, expected: 'admin.accounts.openaiQuotaReset.pointsAvailable' }, + { name: 'invalid balance', credits: { has_credits: true, unlimited: false, balance: 'NaN' }, expected: 'admin.accounts.openaiQuotaReset.pointsAvailable' }, + { name: 'absent credits', credits: undefined, expected: '—' }, + { name: 'null credits', credits: null, expected: '—' }, + ])('queries and displays $name independently of reset cards', async ({ credits, expected }) => { + vi.mocked(refreshOpenAIQuota).mockResolvedValue({ + credits, fetched_at: 1770000000, cache_persisted: false, credits_cache_persisted: true, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({}) } }) + expect(points(wrapper).text()).toContain('—') + await points(wrapper).trigger('click') + await flushPromises() + expect(refreshOpenAIQuota).toHaveBeenCalledWith(1) + expect(points(wrapper).text()).toContain(expected) + expect(resetButton(wrapper).attributes('disabled')).toBeDefined() + expect(resetOpenAIQuota).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('rehydrates points with their query timestamp without an upstream request', () => { + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({ + extra: { codex_credits_snapshot: { credits: balance, fetched_at: 1770000000 } }, + }) } }) + expect(points(wrapper).text()).toContain(balance.balance) + expect(points(wrapper).attributes('title')).toContain('admin.accounts.openaiQuotaReset.pointsUpdatedAt:') + expect(refreshOpenAIQuota).not.toHaveBeenCalled() + expect(resetButton(wrapper).attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('keeps the last balance on a request failure, but clears it on a successful unknown response', async () => { + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({ + extra: { codex_credits_snapshot: { credits: balance, fetched_at: 1770000000 } }, + }) } }) + vi.mocked(refreshOpenAIQuota).mockRejectedValueOnce(new Error('network unavailable')) + await points(wrapper).trigger('click') + await flushPromises() + expect(points(wrapper).text()).toContain(balance.balance) + expect(wrapper.text()).toContain('network unavailable') + + vi.mocked(refreshOpenAIQuota).mockResolvedValueOnce({ fetched_at: 1770000001, cache_persisted: true, credits_cache_persisted: true }) + await points(wrapper).trigger('click') + await flushPromises() + expect(points(wrapper).text()).toContain('—') + expect(wrapper.text()).not.toContain(balance.balance) + wrapper.unmount() + }) + + it('reports a points-cache write failure while displaying the live balance', async () => { + vi.mocked(refreshOpenAIQuota).mockResolvedValue({ + credits: balance, fetched_at: 1770000000, cache_persisted: true, credits_cache_persisted: false, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({}) } }) + await points(wrapper).trigger('click') + await flushPromises() + expect(points(wrapper).text()).toContain(balance.balance) + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.pointsCachePersistFailed') + wrapper.unmount() + }) + + it('does not apply a pending response to another account row', async () => { + let resolve!: (value: OpenAIQuotaRefreshResult) => void + vi.mocked(refreshOpenAIQuota).mockReturnValue(new Promise((done) => { resolve = done })) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({}) } }) + await points(wrapper).trigger('click') + expect(points(wrapper).attributes('disabled')).toBeDefined() + await wrapper.setProps({ account: makeAccount({ id: 2 }) }) + resolve({ credits: balance, fetched_at: 1770000000, cache_persisted: true, credits_cache_persisted: true }) + await flushPromises() + expect(points(wrapper).text()).toContain('—') + expect(points(wrapper).attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) +}) + describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { it('影子账号(parent_account_id 非空)的 reset 按钮被禁用且提示在母账号重置', () => { const account = makeAccount({ parent_account_id: 100 }) @@ -242,6 +327,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { cache_refreshed: true, account_state_recovered: true, quota: { + credits: { has_credits: true, unlimited: false, balance: '999.25' }, rate_limit_reset_credits: { available_count: 0, credits: [], @@ -267,6 +353,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { expect(resetOpenAIQuota).toHaveBeenCalledWith(1) expect(refreshOpenAIQuota).not.toHaveBeenCalled() + expect(wrapper.get('[data-testid="codex-credits"]').text()).toContain('999.25') expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:') expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetSuccess') expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]]) diff --git a/frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts b/frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts new file mode 100644 index 0000000000..f8bb1d9915 --- /dev/null +++ b/frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import OpenAIReferralCell from '../OpenAIReferralCell.vue' +import type { Account } from '@/types' +import type { OpenAIReferralEligibility, OpenAIReferralRefreshResult } from '@/types/openaiReferrals' +import { refreshOpenAIReferrals, sendOpenAIReferralInvite } from '@/api/admin/accounts' + +vi.mock('@/api/admin/accounts', () => ({ refreshOpenAIReferrals: vi.fn(), sendOpenAIReferralInvite: vi.fn() })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string, params?: Record) => params ? `${key}:${Object.values(params).join(',')}` : key }) })) + +const eligibility: OpenAIReferralEligibility = { + should_show: true, remaining_send_capacity: 3, remaining_reward_capacity: 2, + requires_explicit_confirmation: true, program_id: 'codex_referral_consumer', available_invites: 2, + fetched_at: 1770000000, title: 'Test offer', rules: ['Test eligibility rule'], +} +const account = (overrides: Partial = {}) => ({ + id: 1, name: 'Test account', platform: 'openai', type: 'oauth', + ...overrides, +}) as Account +const mountCell = (value = account()) => mount(OpenAIReferralCell, { + props: { account: value }, global: { stubs: { teleport: true } }, +}) +const open = async (wrapper: ReturnType) => { + await wrapper.get('[data-testid="referral-open"]').trigger('click') + await flushPromises() +} +const completeForm = async (wrapper: ReturnType) => { + await wrapper.get('[data-testid="referral-email"]').setValue('friend@example.com') + await wrapper.get('[data-testid="referral-consent"]').setValue(true) +} + +beforeEach(() => { + vi.mocked(refreshOpenAIReferrals).mockReset().mockResolvedValue({ eligibility: { ...eligibility }, cache_persisted: true }) + vi.mocked(sendOpenAIReferralInvite).mockReset() +}) + +describe('OpenAIReferralCell', () => { + it('shows cached capacity without sending requests, then refreshes when opening the form', async () => { + const wrapper = mountCell(account({ extra: { codex_referral_snapshot: eligibility } })) + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('2') + expect(refreshOpenAIReferrals).not.toHaveBeenCalled() + await open(wrapper) + expect(refreshOpenAIReferrals).toHaveBeenCalledWith(1) + expect(wrapper.text()).toContain('Test account') + expect(wrapper.text()).toContain('Test eligibility rule') + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + expect(sendOpenAIReferralInvite).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('requires a valid email and consent, and clears consent when the recipient changes', async () => { + const wrapper = mountCell() + await open(wrapper) + await wrapper.get('[data-testid="referral-email"]').setValue('invalid') + await wrapper.get('[data-testid="referral-consent"]').setValue(true) + await wrapper.get('form').trigger('submit') + expect(sendOpenAIReferralInvite).not.toHaveBeenCalled() + await wrapper.get('[data-testid="referral-email"]').setValue('friend@example.com') + expect((wrapper.get('[data-testid="referral-consent"]').element as HTMLInputElement).checked).toBe(false) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-testid="referral-consent"]').setValue(true) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) + + it.each([0, null])('blocks sends when remaining capacity is %s', async (capacity) => { + vi.mocked(refreshOpenAIReferrals).mockResolvedValue({ eligibility: { ...eligibility, available_invites: capacity }, cache_persisted: true }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + expect(sendOpenAIReferralInvite).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.unavailable') + wrapper.unmount() + }) + + it('sends once and displays the recipient and refreshed count', async () => { + vi.mocked(sendOpenAIReferralInvite).mockResolvedValue({ + sent: true, email: 'friend@example.com', eligibility: { ...eligibility, available_invites: 1 }, cache_persisted: true, refresh_failed: false, + }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(sendOpenAIReferralInvite).toHaveBeenCalledTimes(1) + expect(sendOpenAIReferralInvite).toHaveBeenCalledWith(1, { email: 'friend@example.com', program_id: 'codex_referral_consumer', confirmed: true }) + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.sent:friend@example.com') + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('1') + expect((wrapper.get('[data-testid="referral-email"]').element as HTMLInputElement).value).toBe('') + wrapper.unmount() + }) + + it('keeps send success when the capacity refresh fails and disables another send', async () => { + vi.mocked(sendOpenAIReferralInvite).mockResolvedValue({ sent: true, email: 'friend@example.com', eligibility: null, cache_persisted: true, refresh_failed: true }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.sent:friend@example.com') + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.refreshFailed') + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('—') + await completeForm(wrapper) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('does not automatically retry an uncertain send', async () => { + vi.mocked(sendOpenAIReferralInvite).mockRejectedValue({ reason: 'OPENAI_REFERRAL_SEND_UNKNOWN' }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.sendUnknown') + expect(sendOpenAIReferralInvite).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('prevents sending from a shadow account', () => { + const wrapper = mountCell(account({ parent_account_id: 100 })) + expect(wrapper.get('[data-testid="referral-open"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-testid="referral-open"]').attributes('title')).toContain('shadowHint') + wrapper.unmount() + }) + + it('does not apply a pending query to a different account', async () => { + let resolve!: (value: OpenAIReferralRefreshResult) => void + vi.mocked(refreshOpenAIReferrals).mockReturnValue(new Promise((done) => { resolve = done })) + const wrapper = mountCell() + await wrapper.get('[data-testid="referral-count"]').trigger('click') + await wrapper.setProps({ account: account({ id: 2 }) }) + resolve({ eligibility, cache_persisted: true }) + await flushPromises() + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('—') + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts b/frontend/src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts new file mode 100644 index 0000000000..4f7cf1b6db --- /dev/null +++ b/frontend/src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { AxiosError } from 'axios' +import apiClient from '@/api/client' +import OpenAIReferralCell from '../OpenAIReferralCell.vue' +import type { Account } from '@/types' + +vi.mock('@/i18n', () => ({ getLocale: () => 'en' })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) + +// Exercise the actual accounts API and shared Axios interceptor together. +const originalAdapter = apiClient.defaults.adapter +const mountCell = () => mount(OpenAIReferralCell, { + props: { account: { id: 1, name: 'Test account', platform: 'openai', type: 'oauth' } as Account }, + global: { stubs: { teleport: true } }, +}) +let wrapper: ReturnType | undefined +afterEach(() => { + wrapper?.unmount() + apiClient.defaults.adapter = originalAdapter +}) + +describe('invitation transport failures', () => { + it.each(['ECONNABORTED', 'ETIMEDOUT', 'ERR_NETWORK', 'ERR_CANCELED', undefined])( + 'warns of an uncertain send after %s without retrying', async (code) => { + const adapter = vi.fn(async (config) => { + if (config.url.endsWith('/invite')) throw new AxiosError('Connection lost', code, config) + return { + status: 200, statusText: 'OK', headers: {}, config, + data: { code: 0, data: { cache_persisted: true, eligibility: { + should_show: true, available_invites: 2, requires_explicit_confirmation: true, + program_id: 'codex_referral_consumer', fetched_at: 1770000000, + } } }, + } + }) + apiClient.defaults.adapter = adapter + wrapper = mountCell() + await wrapper.get('[data-testid="referral-open"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="referral-email"]').setValue('friend@example.com') + await wrapper.get('[data-testid="referral-consent"]').setValue(true) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('admin.accounts.openaiReferral.sendUnknown') + expect(wrapper.text()).not.toContain('Network error.') + expect(wrapper.text()).not.toContain('admin.accounts.openaiReferral.sent') + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(adapter.mock.calls.filter(([config]) => config.url.endsWith('/invite'))).toHaveLength(1) + } + ) + + it('keeps a query timeout separate from an uncertain send', async () => { + apiClient.defaults.adapter = vi.fn(async (config) => { + throw new AxiosError('Timed out', 'ECONNABORTED', config) + }) + wrapper = mountCell() + await wrapper.get('[data-testid="referral-open"]').trigger('click') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('Network error. Please check your connection.') + expect(wrapper.text()).not.toContain('admin.accounts.openaiReferral.sendUnknown') + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 8c675a1c31..b25f0f8c87 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -1585,13 +1585,44 @@ export default { estimatedTotalCost: 'Est. total ${cost}', estimatedTotalCostTooltip: 'Estimated total cost at 100% utilization, based on current window cost and utilization' }, + openaiReferral: { + available: 'Invites left', + invite: 'Invite user', + fromAccount: 'Inviting account:', + personal: 'Invite a friend', + workspace: 'Invite a coworker', + email: 'Recipient email', + consent: 'I have this person’s consent to send them an invitation.', + send: 'Send invitation', + sending: 'Sending…', + sent: 'Invitation sent to {email}', + queryHint: 'Click to query remaining invitations', + checkedAt: 'Checked: {time}. Click to refresh.', + unavailable: 'Invitations are unavailable. Eligibility requirements may not be met, or the limit has been reached.', + invalidEmail: 'Enter one valid email address.', + rejected: 'The invitation was rejected. Check the email and offer eligibility.', + alreadyInvited: 'An invitation already exists for this email. Check its status in Codex.', + rateLimited: 'The invitation rate or capacity limit has been reached. Try again later.', + sendUnknown: 'The invitation outcome is unknown. Check its status in Codex before deciding whether to retry.', + programChanged: 'The account’s referral program changed. Refresh eligibility before sending.', + consentRequired: 'Confirm that you have the recipient’s consent first.', + shadowHint: 'Send invitations from the parent account.', + cacheFailed: 'Live capacity was fetched, but the cache could not be saved. Query again.', + refreshFailed: 'The invitation was sent, but remaining capacity could not be refreshed. Query again.' + }, openaiQuotaReset: { - count: 'Credits', + count: 'Resets', + points: 'Points', + pointsUnlimited: 'Unlimited', + pointsAvailable: 'Available', + pointsTooltip: 'Click to query Codex points and reset credits', + pointsUpdatedAt: 'Balance checked: {time}', + pointsCachePersistFailed: 'Showing live points, but the cache could not be saved. Query again.', reset: 'Reset', - countTooltipLoad: 'Click to load the available reset-credit count', - countTooltipRefresh: 'Click to refresh the available reset-credit count', + countTooltipLoad: 'Click to load the available reset-credit count and points balance', + countTooltipRefresh: 'Click to refresh the available reset-credit count and points balance', resetTooltipReady: 'Consume 1 reset credit to immediately restore the window', - resetTooltipNeedQuery: 'Click Credits first to load the available count', + resetTooltipNeedQuery: 'Click Resets first to load the available count', resetTooltipNoCredits: 'No reset credits available', resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account', expiresAt: 'Expires {time}', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index a93e548421..fc4bb94426 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -503,11 +503,42 @@ export default { estimatedTotalCost: '预计总费用 ${cost}', estimatedTotalCostTooltip: '根据当前窗口费用和使用率估算达到 100% 使用率时的总费用' }, + openaiReferral: { + available: '可邀请', + invite: '邀请用户', + fromAccount: '邀请账号:', + personal: '邀请朋友', + workspace: '邀请同事', + email: '受邀人邮箱', + consent: '我已取得此人的同意,可以向其发送邀请。', + send: '发送邀请', + sending: '正在发送…', + sent: '邀请已发送至 {email}', + queryHint: '点击查询剩余可邀请次数', + checkedAt: '查询时间:{time},点击刷新', + unavailable: '当前无法邀请用户,可能未满足活动条件或次数已用完。', + invalidEmail: '请输入一个有效的邮箱地址。', + rejected: '上游拒绝了邀请,请检查邮箱和活动资格。', + alreadyInvited: '此邮箱已有邀请,请在 Codex 中查看邀请状态。', + rateLimited: '邀请频率或次数已达上限,请稍后再试。', + sendUnknown: '暂时无法确认邀请是否已发送,请先在 Codex 中核对邀请状态,再决定是否重试。', + programChanged: '账号的邀请计划已变化,请刷新邀请资格后再发送。', + consentRequired: '请先确认已取得受邀人的同意。', + shadowHint: '请在母账号上发送邀请。', + cacheFailed: '实时次数已获取,但缓存保存失败,请重新查询。', + refreshFailed: '邀请已发送,但剩余次数刷新失败,请重新查询。' + }, openaiQuotaReset: { count: '次数', + points: '点数', + pointsUnlimited: '无限', + pointsAvailable: '可用', + pointsTooltip: '点击查询 Codex 点数余额和重置次数', + pointsUpdatedAt: '余额查询时间:{time}', + pointsCachePersistFailed: '已显示实时点数,但缓存保存失败,请重新查询。', reset: '重置', - countTooltipLoad: '点击查询剩余重置次数', - countTooltipRefresh: '点击刷新剩余重置次数', + countTooltipLoad: '点击查询剩余重置次数和点数余额', + countTooltipRefresh: '点击刷新剩余重置次数和点数余额', resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口', resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数', resetTooltipNoCredits: '没有可用的重置次数', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3ae9838133..80cef707bd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1179,6 +1179,11 @@ export interface Account { available_count?: number credits?: { expires_at?: string }[] } + codex_credits_snapshot?: { + credits: { has_credits: boolean; unlimited: boolean; balance: string | null } | null + fetched_at: number + } + codex_referral_snapshot?: import('./openaiReferrals').OpenAIReferralEligibility | null auto_reset_credit_enabled?: boolean auto_reset_credit_5h_threshold?: number auto_reset_credit_7d_threshold?: number diff --git a/frontend/src/types/openaiReferrals.ts b/frontend/src/types/openaiReferrals.ts new file mode 100644 index 0000000000..a9ef1af965 --- /dev/null +++ b/frontend/src/types/openaiReferrals.ts @@ -0,0 +1,25 @@ +export interface OpenAIReferralEligibility { + should_show: boolean + remaining_send_capacity: number | null + remaining_reward_capacity: number | null + requires_explicit_confirmation: boolean | null + offer_id?: string + title?: string + description?: string + rules?: string[] + grants?: { grant_type: string; recipient: string; amount: number }[] + program_id: string + available_invites: number | null + fetched_at: number +} + +export interface OpenAIReferralRefreshResult { + eligibility: OpenAIReferralEligibility | null + cache_persisted: boolean +} + +export interface OpenAIReferralSendResult extends OpenAIReferralRefreshResult { + email: string + sent: boolean + refresh_failed: boolean +}