diff --git a/.gitignore b/.gitignore index 20ed12275e87..64b5dde4fdc8 100644 --- a/.gitignore +++ b/.gitignore @@ -141,11 +141,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/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index 031fe437fdd5..b96bf426a6da 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 90677b3bf4da..0556a02f89d1 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 000000000000..4025fac65641 --- /dev/null +++ b/backend/internal/handler/admin/openai_referral_handler.go @@ -0,0 +1,96 @@ +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. + ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 8*time.Second) + defer cancel() + eligibility, refreshErr := h.referralService.QueryReferralEligibility(ctx, id) + if refreshErr != nil { + eligibility = nil + } + cacheErr := h.referralService.CacheReferralSnapshot(ctx, 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 000000000000..4bfca710f407 --- /dev/null +++ b/backend/internal/handler/admin/openai_referral_handler_test.go @@ -0,0 +1,90 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type referralHandlerStub struct { + eligibility *service.OpenAIReferralEligibility + queryErr, cacheErr error + input service.OpenAIReferralSendRequest + cached *service.OpenAIReferralEligibility + sends int +} + +func (s *referralHandlerStub) QueryReferralEligibility(context.Context, int64) (*service.OpenAIReferralEligibility, error) { + return s.eligibility, s.queryErr +} +func (s *referralHandlerStub) CacheReferralSnapshot(_ context.Context, _ int64, e *service.OpenAIReferralEligibility) error { + 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) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index c16ef695e03f..9b43abe4b9c6 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/openai_quota_credits_test.go b/backend/internal/service/openai_quota_credits_test.go new file mode 100644 index 000000000000..9a9a6b1adac2 --- /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)) + 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_service.go b/backend/internal/service/openai_quota_service.go index b05241b222f0..6ff3e6df8add 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 } @@ -228,16 +245,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 b1f636d41627..d5cd81767f19 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -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"]) } diff --git a/backend/internal/service/openai_referral_service.go b/backend/internal/service/openai_referral_service.go new file mode 100644 index 000000000000..be9eeb568e47 --- /dev/null +++ b/backend/internal/service/openai_referral_service.go @@ -0,0 +1,227 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/mail" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "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" + openAIReferralSnapshotKey = "codex_referral_snapshot" + openAIReferralConsumer = "codex_referral_consumer" + openAIReferralWorkspace = "codex_referral_workspace" + openAIReferralEntrypoint = "persistent" +) + +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) referralRequest(ctx context.Context, id int64) (*req.Request, error) { + token, accountID, proxy, fedRAMP, err := s.prepareUpstreamCall(ctx, id) + if err != nil { + return nil, err + } + client, err := s.privacyClientFactory(proxy) + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_CLIENT_ERROR", "failed to build referral client") + } + headers, _, err := s.buildCodexQuotaHeaders(ctx, id, token, accountID, fedRAMP) + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_AUTH_ERROR", "failed to authenticate referral request") + } + // Sending an invitation has no documented idempotency key. Never retry it. + return client.R().SetContext(ctx).SetHeaders(headers).SetRetryCount(0), 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() + r, err := s.referralRequest(callCtx, id) + if err != nil { + return nil, err + } + program := referralProgram(account) + resp, err := r.SetQueryParams(map[string]string{"program_id": program, "entrypoint": openAIReferralEntrypoint}).Get(openAIReferralURL + "/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 *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") + } + 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() + r, err := s.referralRequest(callCtx, id) + if err != nil { + return nil, err + } + resp, err := r.SetBody(map[string]any{ + "program_id": eligibility.ProgramID, "entrypoint": openAIReferralEntrypoint, "emails": []string{email}, + }).Post(openAIReferralURL) + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_SEND_UNKNOWN", "invitation outcome is unknown; check the invitation status in Codex before sending again") + } + if !resp.IsSuccessState() { + return nil, 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 nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_SEND_UNKNOWN", "invitation outcome is unknown; check the invitation status in Codex before sending again") + } + return &OpenAIReferralSendResult{Email: email, Sent: true}, nil +} + +// Avoid exposing upstream response bodies, which can contain personal data or +// credentials. The status still distinguishes validation, 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/service/openai_referral_service_test.go b/backend/internal/service/openai_referral_service_test.go new file mode 100644 index 000000000000..47efba6712d4 --- /dev/null +++ b/backend/internal/service/openai_referral_service_test.go @@ -0,0 +1,132 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func referralTestService(t *testing.T, plan string, handler http.HandlerFunc) (*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"}} + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return NewOpenAIQuotaService(repo, nil, NewOpenAITokenProvider(repo, tokens, nil), newQuotaRedirectingFactory(srv)), 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) { + var gets, posts int + svc, repo := referralTestService(t, tc.plan, 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") + switch r.URL.Path { + case "/backend-api/referrals/invite/eligibility": + gets++ + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, tc.program, 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"]}`)) + case "/backend-api/referrals/invite": + posts++ + require.Equal(t, http.MethodPost, r.Method) + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, map[string]any{"program_id": tc.program, "entrypoint": "persistent", "emails": []any{"friend@example.com"}}, body) + _, _ = w.Write([]byte(`{"invites":[{"referral_id":"test-invite","email":"friend@example.com"}]}`)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + }) + 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, 2, gets, "send must recheck eligibility") + require.Equal(t, 1, posts) + }) + } +} + +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}, + {"null response", "a@example.com", `null`, "OPENAI_REFERRAL_INVALID_RESPONSE", true, false}, + {"shadow", "a@example.com", `{}`, "OPENAI_REFERRAL_SHADOW_ACCOUNT", true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + posts := 0 + svc, repo := referralTestService(t, "plus", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posts++ + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.body)) + }) + 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.Zero(t, posts) + }) + } +} + +func TestOpenAIReferralSendDoesNotRetryOrExposeUpstreamErrors(t *testing.T) { + for _, status := range []int{400, 401, 403, 409, 429, 500} { + t.Run(http.StatusText(status), func(t *testing.T) { + posts := 0 + svc, _ := referralTestService(t, "plus", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"should_show":true,"remaining_send_capacity":2,"requires_explicit_confirmation":false,"offer_id":"none"}`)) + return + } + posts++ + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"detail":"secret-token someone@example.com"}`)) + }) + _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: "friend@example.com", ProgramID: openAIReferralConsumer}) + require.Error(t, err) + require.NotContains(t, err.Error(), "secret-token") + require.NotContains(t, err.Error(), "someone@example.com") + require.Equal(t, 1, posts) + }) + } +} diff --git a/docs/codex-credits.md b/docs/codex-credits.md new file mode 100644 index 000000000000..9abc17ca45bd --- /dev/null +++ b/docs/codex-credits.md @@ -0,0 +1,59 @@ +# 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 影子账号可查询母账号次数,但发送需从母账号操作。 + +发送成功后,后端以有时限的独立上下文刷新次数。回读失败仍返回 `sent: true`、`refresh_failed: true`,并尝试清空过期快照。发送请求不自动重试;网络错误或无法确认的响应要求先在 Codex 核对状态。接口错误不会向前端透传上游原始响应中的凭证或无关个人信息。 + +### 界面测试 + +以下截图使用真实 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 000000000000..b0ec8a019d40 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 000000000000..e443315e8b6b Binary files /dev/null and b/docs/screenshots/codex-referral-success.png differ diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 7e6e04280faf..47ffc1c60651 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, @@ -914,9 +915,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 @@ -944,6 +952,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 } /** @@ -962,6 +971,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/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index 6a3225b8bf59..60a8465634e7 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 000000000000..8496a07796ac --- /dev/null +++ b/frontend/src/components/account/OpenAIReferralCell.vue @@ -0,0 +1,220 @@ + + + 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 280725bc5b65..53101d8f9267 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 000000000000..f8bb1d991554 --- /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/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 8c675a1c31cd..b25f0f8c8700 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 a93e5484212d..fc4bb944269c 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 60eb7e58020c..a2022c78dd6b 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 000000000000..a9ef1af965a1 --- /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 +}