feat(codex): recognize current subscription plans

This commit is contained in:
lyy0709
2026-09-29 11:50:08 -07:00
parent 9688571a83
commit c91bb6124a
6 changed files with 135 additions and 146 deletions
@@ -121,3 +121,13 @@ func TestNormalizeOpenAIPersonalAccessTokenCredentialsRemovesOAuthFields(t *test
require.Equal(t, "2026-12-31T00:00:00Z", got["subscription_expires_at"])
require.Equal(t, []any{"custom"}, got["openai_usage_channel_fields"])
}
func TestOpenAICodexSubscriptionSKUsSurviveCredentialBuild(t *testing.T) {
svc := &OpenAIOAuthService{}
for _, plan := range []string{"prolite", "pro", "promax", "ent26", "enterprise_cbp_automation", "enterprise_cbp_usage_based", "edu_plus", "edu_pro", "future_sku"} {
for _, mode := range []string{"", OpenAIAuthModePersonalAccessToken} {
creds := svc.BuildAccountCredentials(&OpenAITokenInfo{AccessToken: "fixture", PlanType: plan, AuthMode: mode})
require.Equal(t, plan, creds["plan_type"])
}
}
}
@@ -1,3 +1,4 @@
import { openAIPlanTypeLabel } from '@/utils/planType'
import { describe, it, expect } from 'vitest'
import {
ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY,
@@ -451,17 +452,17 @@ describe('plan_type helpers', () => {
describe('planTypeDisplayLabel', () => {
it('maps canonical + alias values to friendly labels', () => {
expect(planTypeDisplayLabel('plus')).toBe('Plus')
expect(planTypeDisplayLabel('pro')).toBe('Pro 20x')
expect(planTypeDisplayLabel('chatgptpro')).toBe('Pro 20x')
expect(planTypeDisplayLabel('prolite')).toBe('Pro 5x')
expect(planTypeDisplayLabel('pro')).toBe('Pro 200')
expect(planTypeDisplayLabel('chatgptpro')).toBe('Pro 200')
expect(planTypeDisplayLabel('prolite')).toBe('Pro 100')
expect(planTypeDisplayLabel('free')).toBe('Free')
expect(planTypeDisplayLabel('team')).toBe('Business Standard')
expect(planTypeDisplayLabel('team')).toBe('Business')
expect(planTypeDisplayLabel('self_serve_business_prolite')).toBe('Business Premium')
})
it('normalizes case, separators and surrounding blanks', () => {
expect(planTypeDisplayLabel('CHATGPTPRO')).toBe('Pro 20x')
expect(planTypeDisplayLabel('PROLITE')).toBe('Pro 5x')
expect(planTypeDisplayLabel(' Pro Lite ')).toBe('Pro 5x')
expect(planTypeDisplayLabel('CHATGPTPRO')).toBe('Pro 200')
expect(planTypeDisplayLabel('PROLITE')).toBe('Pro 100')
expect(planTypeDisplayLabel(' Pro Lite ')).toBe('Pro 100')
expect(planTypeDisplayLabel('self-serve-business-pro-lite')).toBe('Business Premium')
})
it('returns unknown values verbatim', () => {
@@ -483,73 +484,37 @@ describe('plan_type helpers', () => {
})
describe('buildPlanTypeOptions', () => {
const clear = 'Clear'
it('returns clear + presets when current is empty', () => {
expect(buildPlanTypeOptions('', clear)).toEqual([
{ value: '', label: clear },
{ value: 'plus', label: 'Plus' },
{ value: 'pro', label: 'Pro 20x' },
{ value: 'prolite', label: 'Pro 5x' },
{ value: 'self_serve_business_prolite', label: 'Business Premium' },
{ value: 'free', label: 'Free' }
])
it('includes all current SKUs with their status labels', () => {
const opts = buildPlanTypeOptions('', 'Clear')
expect(opts[0]).toEqual({ value: '', label: 'Clear' })
expect(opts).toContainEqual({ value: 'prolite', label: 'Pro 100' })
expect(opts).toContainEqual({ value: 'pro', label: 'Pro 200' })
expect(opts).toContainEqual({ value: 'promax', label: 'Pro 500' })
expect(opts).toContainEqual({ value: 'edu_plus', label: 'Edu Plus' })
expect(opts).toContainEqual({ value: 'edu_pro', label: 'Edu Pro' })
expect(opts).toContainEqual({ value: 'enterprise_cbp_automation', label: 'Enterprise (Automation)' })
})
it('keeps canonical chatgptpro under a single friendly "Pro 20x" option (no duplicate)', () => {
const opts = buildPlanTypeOptions('chatgptpro', clear)
const pros = opts.filter(o => o.label === 'Pro 20x')
expect(pros).toHaveLength(1)
expect(pros[0].value).toBe('chatgptpro')
expect(opts.map(o => o.value)).toEqual([
'',
'plus',
'chatgptpro',
'prolite',
'self_serve_business_prolite',
'free'
])
})
it('appends an unknown-but-labeled value (team) as its own option', () => {
const opts = buildPlanTypeOptions('team', clear)
expect(opts.find(o => o.value === 'team')).toEqual({ value: 'team', label: 'Business Standard' })
// presets untouched
expect(opts.map(o => o.value)).toEqual([
'',
'plus',
'pro',
'prolite',
'self_serve_business_prolite',
'free',
'team'
])
})
it('appends a fully custom value with a raw label', () => {
const opts = buildPlanTypeOptions('weird_x', clear)
expect(opts.at(-1)).toEqual({ value: 'weird_x', label: 'weird_x' })
})
it('does not duplicate an exact preset value', () => {
const opts = buildPlanTypeOptions('pro', clear)
expect(opts.filter(o => o.value === 'pro')).toHaveLength(1)
expect(opts.map(o => o.value)).toEqual([
'',
'plus',
'pro',
'prolite',
'self_serve_business_prolite',
'free'
])
})
it('does not duplicate a preset value that is the current one', () => {
for (const preset of ['prolite', 'self_serve_business_prolite']) {
const opts = buildPlanTypeOptions(preset, clear)
expect(opts.filter(o => o.value === preset)).toHaveLength(1)
it('preserves aliases without duplicating the same SKU', () => {
for (const [alias, canonical] of [['chatgpt_pro', 'pro'], ['pro_lite', 'prolite'], ['selfservebusinessprolite', 'self_serve_business_prolite']]) {
const opts = buildPlanTypeOptions(alias, 'Clear')
expect(opts.filter(option => option.value === alias)).toHaveLength(1)
expect(opts.some(option => option.value === canonical)).toBe(false)
}
})
it('keeps a separator-free variant of a preset as its own value', () => {
const opts = buildPlanTypeOptions('selfservebusinessprolite', clear)
expect(opts.find(o => o.value === 'selfservebusinessprolite')).toEqual({
value: 'selfservebusinessprolite',
label: 'Business Premium'
})
it('keeps distinct SKUs that share the Enterprise label', () => {
const opts = buildPlanTypeOptions('ent26', 'Clear')
expect(opts.filter(option => option.label === 'Enterprise').map(option => option.value)).toEqual([
'business', 'enterprise', 'ent26', 'enterprise_cbp_usage_based'
])
for (const value of ['business', 'enterprise', 'ent26', 'enterprise_cbp_usage_based']) {
expect(applyPlanType({}, value)).toEqual({ plan_type: value })
}
})
it('keeps unknown values and exact presets without duplicates', () => {
expect(buildPlanTypeOptions('future_sku', 'Clear').at(-1)).toEqual({ value: 'future_sku', label: 'future_sku' })
for (const value of ['promax', 'team', 'ent26', 'edu_plus']) {
expect(buildPlanTypeOptions(value, 'Clear').filter(option => option.value === value)).toHaveLength(1)
}
})
})
@@ -574,3 +539,14 @@ describe('plan_type helpers', () => {
})
})
})
describe('Codex subscription analytics labels', () => {
it.each([
['business', 'Business'], ['self_serve_business_prolite', 'Business'],
['enterprise_cbp_automation', 'Enterprise'], ['ent26', 'Enterprise'],
['edu', 'Education'], ['edu_plus', 'Education'], ['edu_pro', 'Education'],
['unknown', 'Account'], ['promax', 'Pro 500']
])('groups %s without changing its status label', (sku, label) => {
expect(openAIPlanTypeLabel(sku, 'analytics')).toBe(label)
})
})
@@ -1,4 +1,4 @@
import { openAIPlanTypeLabel } from '@/utils/planType'
import { openAIPlanTypeKey, openAIPlanTypeLabel, openAIPlanTypes } from '@/utils/planType'
export function applyInterceptWarmup(
credentials: Record<string, unknown>,
@@ -503,7 +503,7 @@ export interface PlanTypeOption {
/**
* plan_type 值的友好显示标签(ChatGPT 档位命名)。
* 与 PlatformTypeBadge 共用 openAIPlanTypeLabel,避免两处映射漂移;
* canonical 值 chatgptpro 显示为 Pro 20x,team 显示为 Business Standard。未知值原样返回。
* canonical 值 chatgptpro 显示为 Pro 200,team 显示为 Business。未知值原样返回。
*/
export function planTypeDisplayLabel(value: string): string {
return openAIPlanTypeLabel(value) || value
@@ -518,32 +518,16 @@ export function readPlanType(credentials: Record<string, unknown> | undefined |
return typeof v === 'string' ? v : ''
}
/**
* 构建 plan_type 下拉选项:清空 + Plus/Pro 20x/Pro 5x/Business Premium/Free 预设。
* 若当前值是某预设的别名(如 chatgptpro↔Pro 20x),用当前的 canonical 值占据该
* 标签位(保留 canonical,显示友好标签,避免重复项);若是完全预设外的值
* (如 team 或异常值),追加为一项,避免编辑时下拉丢失原值。
*/
/** Build SKU-preserving choices; aliases replace only the same canonical SKU. */
export function buildPlanTypeOptions(current: string, clearLabel: string): PlanTypeOption[] {
const cur = (current || '').trim()
const curLabel = cur ? planTypeDisplayLabel(cur) : ''
const presets: PlanTypeOption[] = [
{ value: 'plus', label: 'Plus' },
{ value: 'pro', label: 'Pro 20x' },
{ value: 'prolite', label: 'Pro 5x' },
{ value: 'self_serve_business_prolite', label: 'Business Premium' },
{ value: 'free', label: 'Free' }
]
const key = openAIPlanTypeKey(cur)
const opts: PlanTypeOption[] = [{ value: '', label: clearLabel }]
for (const p of presets) {
if (cur && p.value !== cur.toLowerCase() && p.label === curLabel) {
// 当前值是该预设的别名:用 canonical 当前值占位,标签仍显示友好名
opts.push({ value: cur, label: p.label })
} else {
opts.push(p)
}
for (const preset of openAIPlanTypes) {
const value = cur && key === openAIPlanTypeKey(preset) ? cur : preset
opts.push({ value, label: planTypeDisplayLabel(value) })
}
if (cur && !opts.some(o => o.value.toLowerCase() === cur.toLowerCase())) {
if (cur && !opts.some(option => option.value === cur)) {
opts.push({ value: cur, label: planTypeDisplayLabel(cur) })
}
return opts
@@ -118,7 +118,7 @@ const normalizedPlanType = computed(() => normalizePlanType(props.planType))
const planLabel = computed(() => {
if (!normalizedPlanType.value) return ''
// ChatGPT 档位命名(Pro 5x / Pro 20x、Business Standard / Business Premium)只适用于
// ChatGPT 档位命名(Pro 100 / Pro 200 / Pro 500、Business / Business Premium)只适用于
// OpenAI:Antigravity 与 Grok 各自的 pro/team 沿用下面的通用标签。
if (props.platform === 'openai') {
const label = openAIPlanTypeLabel(props.planType)
@@ -259,13 +259,15 @@ const planBadgeClass = computed(() => {
if (normalizedPlanType.value === 'plus') {
return 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300'
}
if (normalizedPlanType.value === 'team' || normalizedPlanType.value === 'selfservebusinessprolite') {
if (normalizedPlanType.value === 'team' || normalizedPlanType.value === 'selfservebusinessprolite' ||
(props.platform === 'openai' && ['selfservebusinessusagebased', 'business', 'enterprise', 'ent26', 'enterprisecbpautomation', 'enterprisecbpusagebased', 'edu', 'eduplus', 'edupro'].includes(normalizedPlanType.value))) {
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
}
if (
normalizedPlanType.value === 'pro' ||
normalizedPlanType.value === 'chatgptpro' ||
normalizedPlanType.value === 'prolite'
normalizedPlanType.value === 'prolite' ||
(props.platform === 'openai' && normalizedPlanType.value === 'promax')
) {
return 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300'
}
@@ -19,29 +19,29 @@ function mountPlan(platform: AccountPlatform, planType: string) {
}
describe('PlatformTypeBadge ChatGPT plan tiers', () => {
it('labels pro / chatgptpro as Pro 20x with the Pro color', () => {
it('labels pro / chatgptpro as Pro 200 with the Pro color', () => {
for (const planType of ['pro', 'chatgptpro', 'PRO']) {
const wrapper = mountPlan('openai', planType)
expect(wrapper.text()).toContain('Pro 20x')
expect(wrapper.text()).toContain('Pro 200')
expect(wrapper.html()).toContain('bg-violet-100')
}
})
it('labels prolite as Pro 5x sharing the Pro color', () => {
it('labels prolite as Pro 100 sharing the Pro color', () => {
for (const planType of ['prolite', 'PROLITE', 'pro_lite']) {
const wrapper = mountPlan('openai', planType)
expect(wrapper.text()).toContain('Pro 5x')
expect(wrapper.text()).toContain('Pro 100')
expect(wrapper.html()).toContain('bg-violet-100')
expect(wrapper.text()).not.toContain('Pro 20x')
expect(wrapper.text()).not.toContain('Pro 200')
}
})
it('labels team as Business Standard with the Team color', () => {
it('labels team as Business with the Team color', () => {
const wrapper = mountPlan('openai', 'team')
expect(wrapper.text()).toContain('Business Standard')
expect(wrapper.text()).toContain('Business')
expect(wrapper.html()).toContain('bg-indigo-100')
})
@@ -70,24 +70,41 @@ describe('PlatformTypeBadge ChatGPT plan tiers', () => {
})
it('falls back to the raw value for unknown plans', () => {
const wrapper = mountPlan('openai', 'enterprise')
const wrapper = mountPlan('openai', 'future_sku')
expect(wrapper.text()).toContain('enterprise')
expect(wrapper.text()).toContain('future_sku')
expect(wrapper.html()).not.toContain('bg-violet-100')
expect(wrapper.html()).not.toContain('bg-indigo-100')
})
it('does not apply the ChatGPT tier naming to other platforms', () => {
// Antigravity 的 Pro 与 Grok 的 pro 是各自产品线的档位,不能显示成 Pro 20x。
// Antigravity 的 Pro 与 Grok 的 pro 是各自产品线的档位,不能显示成 Pro 200。
for (const platform of ['antigravity', 'grok'] as AccountPlatform[]) {
const wrapper = mountPlan(platform, 'pro')
expect(wrapper.text()).toContain('Pro')
expect(wrapper.text()).not.toContain('Pro 20x')
expect(wrapper.text()).not.toContain('Pro 200')
}
const antigravityTeam = mountPlan('antigravity', 'team')
expect(antigravityTeam.text()).toContain('Team')
expect(antigravityTeam.text()).not.toContain('Business Standard')
expect(antigravityTeam.text()).not.toContain('Business')
})
})
describe('new Codex subscription labels', () => {
it.each([
['promax', 'Pro 500', 'bg-violet-100'],
['go', 'Go', ''],
['ent26', 'Enterprise', 'bg-indigo-100'],
['enterprise_cbp_automation', 'Enterprise (Automation)', 'bg-indigo-100'],
['self_serve_business_usage_based', 'Business', 'bg-indigo-100'],
['business', 'Enterprise', 'bg-indigo-100'],
['edu_plus', 'Edu Plus', 'bg-indigo-100'],
['edu_pro', 'Edu Pro', 'bg-indigo-100']
])('displays %s', (sku, label, color) => {
const wrapper = mountPlan('openai', sku)
expect(wrapper.text()).toContain(label)
if (color) expect(wrapper.html()).toContain(color)
})
})
+36 -36
View File
@@ -1,43 +1,43 @@
/**
* ChatGPT(OpenAI)订阅档位 plan_type 的解析与展示映射。
*
* plan_type 由上游原样透传,同一档位会出现 `chatgpt_pro`、`self_serve_business_prolite`
* 这类下划线/别名写法,因此匹配前一律先归一化。
*
* 这里的档位命名只对 OpenAI 平台成立:Antigravity 的 `Pro`、Grok 的 `pro` 是各自产品线的
* 档位,套用 ChatGPT 的倍率命名(Pro 5x / Pro 20x)会显示错误。
*/
/**
* plan_type 归一化:去首尾空白、转小写,并去掉空格/下划线/连字符。
* 例:`self_serve_business_prolite` → `selfservebusinessprolite`。
*/
/** OpenAI subscription SKU identity and Codex display names. Other providers use their own labels. */
export function normalizePlanType(value?: string | null): string {
return (value || '').trim().toLowerCase().replace(/[\s_-]+/g, '')
}
/**
* ChatGPT 档位 → 展示标签;未知档位返回空串,由调用方决定是否回退为原始值。
*
* Pro 的倍率命名:`pro`/`chatgptpro` 为 Pro 20x,`prolite` 为 Pro 5x;
* Team/Business:`team` 为 Business Standard,`self_serve_business_prolite` 为 Business Premium。
*/
export function openAIPlanTypeLabel(value?: string | null): string {
switch (normalizePlanType(value)) {
case 'plus':
return 'Plus'
case 'chatgptpro':
case 'pro':
return 'Pro 20x'
case 'prolite':
return 'Pro 5x'
case 'selfservebusinessprolite':
return 'Business Premium'
/** Canonical comparison key; labels may be shared by distinct SKUs. */
export function openAIPlanTypeKey(value?: string | null): string {
const key = normalizePlanType(value)
return key === 'chatgptpro' ? 'pro' : key
}
// Wire names from openai/codex b1e72963, protocol/src/account.rs.
export const openAIPlanTypes = [
'free', 'go', 'plus', 'prolite', 'pro', 'promax', 'team',
'self_serve_business_usage_based', 'self_serve_business_prolite', 'business',
'enterprise', 'ent26', 'enterprise_cbp_usage_based', 'enterprise_cbp_automation',
'edu', 'edu_plus', 'edu_pro', 'unknown'
] as const
/** Keep Status SKU names distinct from the account families used in analytics. */
export function openAIPlanTypeLabel(value?: string | null, display: 'status' | 'analytics' = 'status'): string {
switch (openAIPlanTypeKey(value)) {
case 'free': return 'Free'
case 'go': return 'Go'
case 'plus': return 'Plus'
case 'prolite': return 'Pro 100'
case 'pro': return 'Pro 200'
case 'promax': return 'Pro 500'
case 'team':
return 'Business Standard'
case 'free':
return 'Free'
default:
return ''
case 'selfservebusinessusagebased': return 'Business'
case 'business': return display === 'status' ? 'Enterprise' : 'Business'
case 'selfservebusinessprolite': return display === 'status' ? 'Business Premium' : 'Business'
case 'enterprisecbpautomation': return display === 'status' ? 'Enterprise (Automation)' : 'Enterprise'
case 'enterprise':
case 'ent26':
case 'enterprisecbpusagebased': return 'Enterprise'
case 'edu': return display === 'status' ? 'Edu' : 'Education'
case 'eduplus': return display === 'status' ? 'Edu Plus' : 'Education'
case 'edupro': return display === 'status' ? 'Edu Pro' : 'Education'
case 'unknown': return display === 'status' ? 'Unknown' : 'Account'
default: return ''
}
}