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:
Wesley Liddick
2026-09-20 16:00:28 +08:00
committed by GitHub
38 changed files with 1871 additions and 40 deletions
+3 -3
View File
@@ -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")
}
+1
View File
@@ -162,6 +162,7 @@ var ProviderSet = wire.NewSet(
NewClaudeOAuthClient,
NewHTTPUpstream,
NewOpenAIOAuthClient,
NewOpenAIReferralClient,
NewGrokOAuthClient,
NewGeminiOAuthClient,
NewGeminiCliCodeAssistClient,
+2
View File
@@ -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)
}
+10 -2
View File
@@ -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,