diff --git a/backend/internal/service/openai_codex_pat_service_test.go b/backend/internal/service/openai_codex_pat_service_test.go index 3210ed3062..ecec9f9335 100644 --- a/backend/internal/service/openai_codex_pat_service_test.go +++ b/backend/internal/service/openai_codex_pat_service_test.go @@ -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"]) + } + } +} diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index f0c5b66165..f686f958f9 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -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) + }) +}) diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 09fc1896c9..c85ef5267a 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -1,4 +1,4 @@ -import { openAIPlanTypeLabel } from '@/utils/planType' +import { openAIPlanTypeKey, openAIPlanTypeLabel, openAIPlanTypes } from '@/utils/planType' export function applyInterceptWarmup( credentials: Record, @@ -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 | 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 diff --git a/frontend/src/components/common/PlatformTypeBadge.vue b/frontend/src/components/common/PlatformTypeBadge.vue index fc295f693a..f0831c593a 100644 --- a/frontend/src/components/common/PlatformTypeBadge.vue +++ b/frontend/src/components/common/PlatformTypeBadge.vue @@ -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' } diff --git a/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts b/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts index e8ce24ecd5..a0802a049d 100644 --- a/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts +++ b/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts @@ -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) }) }) diff --git a/frontend/src/utils/planType.ts b/frontend/src/utils/planType.ts index c1514763aa..592e496274 100644 --- a/frontend/src/utils/planType.ts +++ b/frontend/src/utils/planType.ts @@ -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 '' } }