mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3641 from infinityf4p/fix/openai-plan-type-workspace
fix(openai): avoid inactive workspace plan type overrides
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user