Merge pull request #3641 from infinityf4p/fix/openai-plan-type-workspace

fix(openai): avoid inactive workspace plan type overrides
This commit is contained in:
Wesley Liddick
2026-07-02 17:33:59 +08:00
committed by GitHub
3 changed files with 144 additions and 5 deletions
@@ -270,7 +270,11 @@ func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *Ope
}
}
if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil {
if info.PlanType != "" {
// chatgpt_plan_type from the ID token is the canonical personal-plan value.
// accounts/check is a multi-account/workspace endpoint; inactive team or
// business workspaces can otherwise overwrite Pro/Free with internal
// workspace billing plan names such as self_serve_business_usage_based.
if shouldApplyChatGPTAccountInfoPlanType(tokenInfo.PlanType, info.PlanType) {
tokenInfo.PlanType = info.PlanType
}
if info.SubscriptionExpiresAt != "" {
@@ -290,6 +294,10 @@ func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *Ope
tokenInfo.PrivacyMode = disableOpenAITraining(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL)
}
func shouldApplyChatGPTAccountInfoPlanType(current, candidate string) bool {
return strings.TrimSpace(candidate) != "" && strings.TrimSpace(current) == ""
}
func resolveChatGPTSubscriptionAccountID(tokenInfo *OpenAITokenInfo, orgID string) string {
for _, candidate := range []string{
tokenInfo.ChatGPTAccountID,
@@ -93,9 +93,10 @@ type ChatGPTAccountInfo struct {
SubscriptionExpiresAt string // entitlement.expires_at (RFC3339)
}
const chatGPTAccountsCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
var chatGPTSubscriptionsURL = "https://chatgpt.com/backend-api/subscriptions"
var (
chatGPTAccountsCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
chatGPTSubscriptionsURL = "https://chatgpt.com/backend-api/subscriptions"
)
// fetchChatGPTAccountInfo calls ChatGPT backend-api to get account info (plan_type, etc.).
// Used as fallback when id_token doesn't contain these fields (e.g., Mobile RT).
@@ -147,7 +148,9 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac
if orgID != "" {
if acctRaw, ok := accounts[orgID]; ok {
if acct, ok := acctRaw.(map[string]any); ok {
fillAccountInfo(info, acct)
if isUsableChatGPTAccountCandidate(acct, time.Now()) {
fillAccountInfo(info, acct)
}
}
}
}
@@ -164,6 +167,9 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac
if !ok {
continue
}
if !isUsableChatGPTAccountCandidate(acct, time.Now()) {
continue
}
planType := extractPlanType(acct)
if planType == "" {
continue
@@ -278,6 +284,46 @@ func extractPlanType(acct map[string]any) string {
return ""
}
func isUsableChatGPTAccountCandidate(acct map[string]any, now time.Time) bool {
if acct == nil || hasChatGPTAccountDeactivatedMarker(acct) {
return false
}
if account, ok := acct["account"].(map[string]any); ok && hasChatGPTAccountDeactivatedMarker(account) {
return false
}
expiresAt := extractEntitlementExpiresAt(acct)
if expiresAt == "" {
return true
}
expiry, err := time.Parse(time.RFC3339, expiresAt)
if err != nil {
return true
}
return expiry.After(now)
}
func hasChatGPTAccountDeactivatedMarker(obj map[string]any) bool {
for _, key := range []string{"deactivated", "is_deactivated", "disabled", "is_disabled"} {
if value, ok := obj[key].(bool); ok && value {
return true
}
}
for _, key := range []string{"deactivated_at", "disabled_at", "deleted_at"} {
if value, ok := obj[key].(string); ok && strings.TrimSpace(value) != "" {
return true
}
}
for _, key := range []string{"status", "state"} {
value, _ := obj[key].(string)
switch strings.ToLower(strings.TrimSpace(value)) {
case "deactivated", "disabled", "deleted", "inactive", "suspended":
return true
}
}
return false
}
// extractEntitlementExpiresAt 从 entitlement 中提取 expires_at。
// 预期为 RFC3339 字符串格式,如 "2026-05-02T20:32:12+00:00"。
func extractEntitlementExpiresAt(acct map[string]any) string {
@@ -40,3 +40,88 @@ func TestFetchChatGPTSubscriptionExpiresAt(t *testing.T) {
require.Equal(t, wantExpiresAt, got)
}
func TestFetchChatGPTAccountInfo_SkipsExpiredWorkspaceCandidate(t *testing.T) {
expiredAt := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/backend-api/accounts/check/v4-2023-04-27", r.URL.Path)
require.Equal(t, "Bearer access-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"accounts": map[string]any{
"org-expired-workspace": map[string]any{
"account": map[string]any{
"plan_type": "self_serve_business_usage_based",
"is_default": true,
},
"entitlement": map[string]any{
"expires_at": expiredAt,
},
},
"personal-account": map[string]any{
"account": map[string]any{
"plan_type": "free",
},
},
},
})
}))
defer server.Close()
oldURL := chatGPTAccountsCheckURL
chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27"
t.Cleanup(func() { chatGPTAccountsCheckURL = oldURL })
got := fetchChatGPTAccountInfo(context.Background(), func(proxyURL string) (*req.Client, error) {
return req.C().SetTimeout(5 * time.Second), nil
}, "access-token", "", "org-expired-workspace")
require.NotNil(t, got)
require.Equal(t, "free", got.PlanType)
require.Empty(t, got.SubscriptionExpiresAt)
}
func TestFetchChatGPTAccountInfo_SkipsDeactivatedWorkspaceCandidate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/backend-api/accounts/check/v4-2023-04-27", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"accounts": map[string]any{
"org-deactivated-workspace": map[string]any{
"account": map[string]any{
"plan_type": "self_serve_business_usage_based",
"is_default": true,
"is_deactivated": true,
},
},
"personal-account": map[string]any{
"account": map[string]any{
"plan_type": "pro",
},
},
},
})
}))
defer server.Close()
oldURL := chatGPTAccountsCheckURL
chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27"
t.Cleanup(func() { chatGPTAccountsCheckURL = oldURL })
got := fetchChatGPTAccountInfo(context.Background(), func(proxyURL string) (*req.Client, error) {
return req.C().SetTimeout(5 * time.Second), nil
}, "access-token", "", "org-deactivated-workspace")
require.NotNil(t, got)
require.Equal(t, "pro", got.PlanType)
}
func TestShouldApplyChatGPTAccountInfoPlanType(t *testing.T) {
require.False(t, shouldApplyChatGPTAccountInfoPlanType("pro", "self_serve_business_usage_based"))
require.False(t, shouldApplyChatGPTAccountInfoPlanType("free", "team"))
require.False(t, shouldApplyChatGPTAccountInfoPlanType("", ""))
require.True(t, shouldApplyChatGPTAccountInfoPlanType("", "pro"))
}