mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 09:09:53 +00:00
Merge pull request #7401 from StarryKira/codex/codex-credits-referrals-upstream
feat(openai): display Codex credits and support referral invitations
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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": {},
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -162,6 +162,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewClaudeOAuthClient,
|
||||
NewHTTPUpstream,
|
||||
NewOpenAIOAuthClient,
|
||||
NewOpenAIReferralClient,
|
||||
NewGrokOAuthClient,
|
||||
NewGeminiOAuthClient,
|
||||
NewGeminiCliCodeAssistClient,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <a@example.com>", `{}`, "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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 响应,不包含真实账号凭证,也没有发送邮件。
|
||||
|
||||

|
||||
|
||||

|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -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.',
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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<OpenAIQuotaRefresh
|
||||
return data
|
||||
}
|
||||
|
||||
export async function refreshOpenAIReferrals(id: number): Promise<OpenAIReferralRefreshResult> {
|
||||
const { data } = await apiClient.post<OpenAIReferralRefreshResult>(
|
||||
`/admin/openai/accounts/${id}/referrals/refresh`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function sendOpenAIReferralInvite(
|
||||
id: number,
|
||||
input: { email: string; program_id: string; confirmed: boolean }
|
||||
): Promise<OpenAIReferralSendResult> {
|
||||
const { data } = await apiClient.post<OpenAIReferralSendResult>(
|
||||
`/admin/openai/accounts/${id}/referrals/invite`, input, { timeout: 90_000 }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one rate-limit-reset credit for an OpenAI/Codex OAuth account.
|
||||
*
|
||||
|
||||
@@ -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.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
-->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<slot name="pre-actions" />
|
||||
@@ -61,6 +61,23 @@
|
||||
</svg>
|
||||
{{ t('admin.accounts.openaiQuotaReset.reset') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="codex-credits"
|
||||
class="inline-flex max-w-full items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 transition-colors hover:bg-emerald-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-emerald-400 dark:hover:bg-emerald-900/30"
|
||||
:disabled="loading || resetting"
|
||||
:title="creditsButtonTitle"
|
||||
@click="handleQuery()"
|
||||
>
|
||||
{{ t('admin.accounts.openaiQuotaReset.points') }}
|
||||
<span class="truncate tabular-nums">{{ creditsDisplay }}</span>
|
||||
</button>
|
||||
<OpenAIReferralCell :account="account" />
|
||||
</div>
|
||||
|
||||
<div v-if="creditsCacheWarning" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.openaiQuotaReset.pointsCachePersistFailed') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -174,6 +191,7 @@ import {
|
||||
type OpenAIQuotaResetResult
|
||||
} from '@/api/admin/accounts'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import OpenAIReferralCell from '@/components/account/OpenAIReferralCell.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
@@ -197,6 +215,38 @@ const resetMessage = ref<string | null>(null)
|
||||
const resetWarning = ref<string | null>(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<NonNullable<Account['extra']>['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
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="referral-count"
|
||||
class="rounded px-1.5 py-0.5 text-[10px] font-medium text-violet-600 hover:bg-violet-50 disabled:opacity-50 dark:text-violet-400 dark:hover:bg-violet-900/30"
|
||||
:disabled="loading || sending"
|
||||
:title="countTitle"
|
||||
@click="refresh()"
|
||||
>
|
||||
{{ t('admin.accounts.openaiReferral.available') }} {{ countDisplay }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="referral-open"
|
||||
class="rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
|
||||
:disabled="sending || isShadow"
|
||||
:title="isShadow ? t('admin.accounts.openaiReferral.shadowHint') : undefined"
|
||||
@click="openDialog"
|
||||
>
|
||||
{{ t('admin.accounts.openaiReferral.invite') }}
|
||||
</button>
|
||||
<span v-if="error && !show" class="max-w-48 truncate text-[10px] text-red-600" :title="error">{{ error }}</span>
|
||||
<span v-if="warning && !show" class="max-w-48 truncate text-[10px] text-amber-700 dark:text-amber-400" :title="warning">{{ warning }}</span>
|
||||
<BaseDialog
|
||||
v-if="show"
|
||||
:show="show"
|
||||
:title="t('admin.accounts.openaiReferral.invite')"
|
||||
:show-close-button="!sending"
|
||||
:close-on-escape="!sending"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<form :id="formID" class="space-y-4" @submit.prevent="sendInvite">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.accounts.openaiReferral.fromAccount') }} <strong>{{ account.name }}</strong>
|
||||
</p>
|
||||
<div class="flex items-center justify-between rounded-lg bg-violet-50 p-3 text-sm dark:bg-violet-900/20">
|
||||
<div>
|
||||
<p class="font-medium text-violet-800 dark:text-violet-300">{{ programLabel }}</p>
|
||||
<p class="mt-1 text-gray-600 dark:text-gray-300">{{ t('admin.accounts.openaiReferral.available') }} {{ countDisplay }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="loading || sending" @click="refresh()">
|
||||
{{ loading ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="eligibility?.title || eligibility?.description || eligibility?.rules?.length" class="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p v-if="eligibility.title" class="font-medium">{{ eligibility.title }}</p>
|
||||
<p v-if="eligibility.description">{{ eligibility.description }}</p>
|
||||
<ul v-if="eligibility.rules?.length" class="list-disc space-y-1 pl-5">
|
||||
<li v-for="(rule, index) in eligibility.rules" :key="index">{{ rule }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-if="fresh && (!eligibility?.should_show || count === null || count <= 0)" class="text-sm text-amber-700 dark:text-amber-400">
|
||||
{{ t('admin.accounts.openaiReferral.unavailable') }}
|
||||
</p>
|
||||
<div>
|
||||
<label :for="emailID" class="input-label">{{ t('admin.accounts.openaiReferral.email') }}</label>
|
||||
<input
|
||||
:id="emailID"
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
maxlength="254"
|
||||
required
|
||||
:disabled="sending"
|
||||
placeholder="friend@example.com"
|
||||
data-testid="referral-email"
|
||||
/>
|
||||
</div>
|
||||
<label v-if="needsConsent" class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<input v-model="confirmed" type="checkbox" class="mt-1" :disabled="sending" data-testid="referral-consent" />
|
||||
{{ t('admin.accounts.openaiReferral.consent') }}
|
||||
</label>
|
||||
<p v-if="error" role="alert" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
<p v-if="sentEmail" role="status" class="text-sm text-emerald-700 dark:text-emerald-400">
|
||||
{{ t('admin.accounts.openaiReferral.sent', { email: sentEmail }) }}
|
||||
</p>
|
||||
<p v-if="warning" role="status" class="text-sm text-amber-700 dark:text-amber-400">{{ warning }}</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<button type="button" class="btn btn-secondary" :disabled="sending" @click="closeDialog">{{ t('common.close') }}</button>
|
||||
<button type="submit" :form="formID" class="btn btn-primary" :disabled="!canSend" data-testid="referral-send">
|
||||
{{ sending ? t('admin.accounts.openaiReferral.sending') : t('admin.accounts.openaiReferral.send') }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import type { OpenAIReferralEligibility } from '@/types/openaiReferrals'
|
||||
import { refreshOpenAIReferrals, sendOpenAIReferralInvite } from '@/api/admin/accounts'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ account: Account }>()
|
||||
const { t } = useI18n()
|
||||
const eligibility = ref<OpenAIReferralEligibility | null>(props.account.extra?.codex_referral_snapshot ?? null)
|
||||
const show = ref(false)
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const fresh = ref(false)
|
||||
const email = ref('')
|
||||
const confirmed = ref(false)
|
||||
const error = ref('')
|
||||
const warning = ref('')
|
||||
const sentEmail = ref('')
|
||||
let generation = 0
|
||||
const isShadow = computed(() => props.account.parent_account_id != null)
|
||||
const formID = computed(() => `codex-referral-form-${props.account.id}`)
|
||||
const emailID = computed(() => `codex-referral-email-${props.account.id}`)
|
||||
const count = computed(() => {
|
||||
const value = eligibility.value?.available_invites
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null
|
||||
})
|
||||
const countDisplay = computed(() => count.value ?? '—')
|
||||
const countTitle = computed(() => {
|
||||
const time = eligibility.value?.fetched_at
|
||||
return time ? t('admin.accounts.openaiReferral.checkedAt', { time: new Date(time * 1000).toLocaleString() }) : t('admin.accounts.openaiReferral.queryHint')
|
||||
})
|
||||
const programLabel = computed(() => t(eligibility.value?.program_id === 'codex_referral_workspace'
|
||||
? 'admin.accounts.openaiReferral.workspace' : 'admin.accounts.openaiReferral.personal'))
|
||||
const needsConsent = computed(() => eligibility.value?.requires_explicit_confirmation !== false)
|
||||
const canSend = computed(() => fresh.value && !isShadow.value && !loading.value && !sending.value
|
||||
&& eligibility.value?.should_show === true && count.value !== null && count.value > 0
|
||||
&& /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim()) && (!needsConsent.value || confirmed.value))
|
||||
|
||||
function errorMessage(value: unknown, duringSend = false): string {
|
||||
const err = value as { status?: number; reason?: string; code?: string; message?: string }
|
||||
const key = err.reason || err.code || ''
|
||||
// The server may have sent the email before the response was lost. Only
|
||||
// sends have an uncertain outcome; a failed eligibility query is read-only.
|
||||
if (duringSend && (err.status === 0 || ['ECONNABORTED', 'ETIMEDOUT', 'ERR_NETWORK', 'ERR_CANCELED'].includes(key))) {
|
||||
return t('admin.accounts.openaiReferral.sendUnknown')
|
||||
}
|
||||
const keys: Record<string, string> = {
|
||||
OPENAI_REFERRAL_UNAVAILABLE: 'unavailable', OPENAI_REFERRAL_FORBIDDEN: 'unavailable',
|
||||
OPENAI_REFERRAL_INVALID_EMAIL: 'invalidEmail', OPENAI_REFERRAL_REJECTED: 'rejected',
|
||||
OPENAI_REFERRAL_ALREADY_EXISTS: 'alreadyInvited', OPENAI_REFERRAL_RATE_LIMITED: 'rateLimited',
|
||||
OPENAI_REFERRAL_SEND_UNKNOWN: 'sendUnknown',
|
||||
OPENAI_REFERRAL_PROGRAM_CHANGED: 'programChanged', OPENAI_REFERRAL_CONFIRMATION_REQUIRED: 'consentRequired',
|
||||
}
|
||||
return keys[key] ? t(`admin.accounts.openaiReferral.${keys[key]}`) : err.message || t('common.error')
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (loading.value || sending.value) return
|
||||
const current = generation
|
||||
loading.value = true
|
||||
fresh.value = false
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
try {
|
||||
const result = await refreshOpenAIReferrals(props.account.id)
|
||||
if (current !== generation) return
|
||||
eligibility.value = result.eligibility
|
||||
fresh.value = result.eligibility !== null
|
||||
if (!result.cache_persisted) warning.value = t('admin.accounts.openaiReferral.cacheFailed')
|
||||
} catch (err) {
|
||||
if (current === generation) error.value = errorMessage(err)
|
||||
} finally {
|
||||
if (current === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
if (isShadow.value || sending.value) return
|
||||
show.value = true
|
||||
email.value = ''
|
||||
confirmed.value = false
|
||||
sentEmail.value = ''
|
||||
void refresh()
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (!sending.value) show.value = false
|
||||
}
|
||||
|
||||
async function sendInvite() {
|
||||
if (!canSend.value || !eligibility.value) return
|
||||
const current = generation
|
||||
sending.value = true
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
sentEmail.value = ''
|
||||
try {
|
||||
const result = await sendOpenAIReferralInvite(props.account.id, {
|
||||
email: email.value.trim(), program_id: eligibility.value.program_id, confirmed: confirmed.value,
|
||||
})
|
||||
if (current !== generation) return
|
||||
if (!result.sent) throw { code: 'OPENAI_REFERRAL_SEND_UNKNOWN' }
|
||||
eligibility.value = result.eligibility
|
||||
fresh.value = !result.refresh_failed && result.eligibility !== null
|
||||
sentEmail.value = result.email
|
||||
email.value = ''
|
||||
confirmed.value = false
|
||||
if (result.refresh_failed) warning.value = t('admin.accounts.openaiReferral.refreshFailed')
|
||||
else if (!result.cache_persisted) warning.value = t('admin.accounts.openaiReferral.cacheFailed')
|
||||
} catch (err) {
|
||||
if (current !== generation) return
|
||||
fresh.value = false
|
||||
error.value = errorMessage(err, true)
|
||||
} finally {
|
||||
if (current === generation) sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(email, () => { confirmed.value = false })
|
||||
watch(() => props.account.id, () => {
|
||||
generation++
|
||||
eligibility.value = props.account.extra?.codex_referral_snapshot ?? null
|
||||
show.value = false
|
||||
fresh.value = false
|
||||
loading.value = false
|
||||
sending.value = false
|
||||
email.value = ''
|
||||
confirmed.value = false
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
sentEmail.value = ''
|
||||
})
|
||||
</script>
|
||||
+88
-1
@@ -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<typeof mount>) => 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]])
|
||||
|
||||
@@ -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<string, unknown>) => 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<Account> = {}) => ({
|
||||
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<typeof mountCell>) => {
|
||||
await wrapper.get('[data-testid="referral-open"]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
const completeForm = async (wrapper: ReturnType<typeof mountCell>) => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof mountCell> | 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()
|
||||
})
|
||||
})
|
||||
@@ -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}',
|
||||
|
||||
@@ -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: '没有可用的重置次数',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user