fix(openai): map new ChatGPT plan tiers to friendly labels

prolite -> Pro 5x (violet, same as Pro), pro/chatgptpro -> Pro 20x, self_serve_business_prolite -> Business Premium (indigo, same as Team), team -> Business Standard.

The tier naming stays OpenAI-only so Antigravity Pro and Grok pro keep their own labels; planTypeDisplayLabel now shares openAIPlanTypeLabel with PlatformTypeBadge instead of duplicating the mapping, and the manual plan_type dropdown gains the Pro 5x / Business Premium presets.
This commit is contained in:
Zero Clover
2026-09-11 03:25:44 +08:00
parent 98d86915be
commit 8f2cba0c20
6 changed files with 220 additions and 36 deletions
@@ -3419,7 +3419,8 @@ const openaiPassthroughEnabled = ref(false)
// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
const openaiFlattenNamespacesEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别
// OpenAI 订阅档位(Plus / Pro 20x / Pro 5x / Business Standard / Business Premium / Free)手动覆盖值,
// 存于 credentials.plan_type;'' 表示清空/自动识别
const editPlanType = ref<string>('')
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
@@ -5210,7 +5211,8 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra
}
// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
// OpenAI: 手动覆盖订阅档位 plan_type(Plus / Pro 20x / Pro 5x / Business Standard / Business Premium / Free)。
// 仅 OAuth 非影子账号:
// 影子账号凭据由母账号管理(且后端会 sanitize),setup-token 无订阅调度语义。
if (props.account.platform === 'openai' && props.account.type === 'oauth' && !isSparkShadow.value) {
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
@@ -396,11 +396,18 @@ describe('plan_type helpers', () => {
describe('planTypeDisplayLabel', () => {
it('maps canonical + alias values to friendly labels', () => {
expect(planTypeDisplayLabel('plus')).toBe('Plus')
expect(planTypeDisplayLabel('pro')).toBe('Pro')
expect(planTypeDisplayLabel('chatgptpro')).toBe('Pro')
expect(planTypeDisplayLabel('pro')).toBe('Pro 20x')
expect(planTypeDisplayLabel('chatgptpro')).toBe('Pro 20x')
expect(planTypeDisplayLabel('prolite')).toBe('Pro 5x')
expect(planTypeDisplayLabel('free')).toBe('Free')
expect(planTypeDisplayLabel('team')).toBe('Team')
expect(planTypeDisplayLabel('CHATGPTPRO')).toBe('Pro')
expect(planTypeDisplayLabel('team')).toBe('Business Standard')
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('self-serve-business-pro-lite')).toBe('Business Premium')
})
it('returns unknown values verbatim', () => {
expect(planTypeDisplayLabel('self_serve_business')).toBe('self_serve_business')
@@ -426,22 +433,39 @@ describe('plan_type helpers', () => {
expect(buildPlanTypeOptions('', clear)).toEqual([
{ value: '', label: clear },
{ value: 'plus', label: 'Plus' },
{ value: 'pro', label: 'Pro' },
{ value: 'pro', label: 'Pro 20x' },
{ value: 'prolite', label: 'Pro 5x' },
{ value: 'self_serve_business_prolite', label: 'Business Premium' },
{ value: 'free', label: 'Free' }
])
})
it('keeps canonical chatgptpro under a single friendly "Pro" option (no duplicate)', () => {
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')
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', 'free'])
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: 'Team' })
expect(opts.find(o => o.value === 'team')).toEqual({ value: 'team', label: 'Business Standard' })
// presets untouched
expect(opts.map(o => o.value)).toEqual(['', 'plus', 'pro', 'free', 'team'])
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)
@@ -450,7 +474,27 @@ describe('plan_type helpers', () => {
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', 'free'])
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('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'
})
})
})
@@ -1,3 +1,5 @@
import { openAIPlanTypeLabel } from '@/utils/planType'
export function applyInterceptWarmup(
credentials: Record<string, unknown>,
enabled: boolean,
@@ -406,23 +408,12 @@ export interface PlanTypeOption {
}
/**
* plan_type 值的友好显示标签,镜像 PlatformTypeBadge 的映射
* (canonical 值 chatgptpro 显示为 Pro,team 显示为 Team)。未知值原样返回。
* plan_type 值的友好显示标签(ChatGPT 档位命名)。
* 与 PlatformTypeBadge 共用 openAIPlanTypeLabel,避免两处映射漂移;
* canonical 值 chatgptpro 显示为 Pro 20x,team 显示为 Business Standard。未知值原样返回。
*/
export function planTypeDisplayLabel(value: string): string {
switch (value.trim().toLowerCase()) {
case 'plus':
return 'Plus'
case 'pro':
case 'chatgptpro':
return 'Pro'
case 'free':
return 'Free'
case 'team':
return 'Team'
default:
return value
}
return openAIPlanTypeLabel(value) || value
}
/**
@@ -435,8 +426,8 @@ export function readPlanType(credentials: Record<string, unknown> | undefined |
}
/**
* 构建 plan_type 下拉选项:清空 + Plus/Pro/Free 预设。
* 若当前值是某预设的别名(如 chatgptpro↔Pro),用当前的 canonical 值占据该
* 构建 plan_type 下拉选项:清空 + Plus/Pro 20x/Pro 5x/Business Premium/Free 预设。
* 若当前值是某预设的别名(如 chatgptpro↔Pro 20x),用当前的 canonical 值占据该
* 标签位(保留 canonical,显示友好标签,避免重复项);若是完全预设外的值
* (如 team 或异常值),追加为一项,避免编辑时下拉丢失原值。
*/
@@ -445,7 +436,9 @@ export function buildPlanTypeOptions(current: string, clearLabel: string): PlanT
const curLabel = cur ? planTypeDisplayLabel(cur) : ''
const presets: PlanTypeOption[] = [
{ value: 'plus', label: 'Plus' },
{ value: 'pro', label: 'Pro' },
{ value: 'pro', label: 'Pro 20x' },
{ value: 'prolite', label: 'Pro 5x' },
{ value: 'self_serve_business_prolite', label: 'Business Premium' },
{ value: 'free', label: 'Free' }
]
const opts: PlanTypeOption[] = [{ value: '', label: clearLabel }]
@@ -69,6 +69,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AccountPlatform, AccountType } from '@/types'
import { platformLabel as sharedPlatformLabel } from '@/utils/platformColors'
import { normalizePlanType, openAIPlanTypeLabel } from '@/utils/planType'
import GrokFreeIcon from './GrokFreeIcon.vue'
import PlatformIcon from './PlatformIcon.vue'
import Icon from '@/components/icons/Icon.vue'
@@ -113,12 +114,16 @@ const typeLabel = computed(() => {
}
})
const normalizedPlanType = computed(() =>
(props.planType || '').trim().toLowerCase().replace(/[\s_-]+/g, '')
)
const normalizedPlanType = computed(() => normalizePlanType(props.planType))
const planLabel = computed(() => {
if (!normalizedPlanType.value) return ''
// ChatGPT 档位命名(Pro 5x / Pro 20x、Business Standard / Business Premium)只适用于
// OpenAI:Antigravity 与 Grok 各自的 pro/team 沿用下面的通用标签。
if (props.platform === 'openai') {
const label = openAIPlanTypeLabel(props.planType)
if (label) return label
}
switch (normalizedPlanType.value) {
case 'plus':
return 'Plus'
@@ -254,10 +259,14 @@ 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') {
if (normalizedPlanType.value === 'team' || normalizedPlanType.value === 'selfservebusinessprolite') {
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
}
if (normalizedPlanType.value === 'pro' || normalizedPlanType.value === 'chatgptpro') {
if (
normalizedPlanType.value === 'pro' ||
normalizedPlanType.value === 'chatgptpro' ||
normalizedPlanType.value === 'prolite'
) {
return 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300'
}
return typeClass.value
@@ -0,0 +1,93 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { AccountPlatform } from '@/types'
import PlatformTypeBadge from '../PlatformTypeBadge.vue'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
function mountPlan(platform: AccountPlatform, planType: string) {
return mount(PlatformTypeBadge, {
props: { platform, type: 'oauth', planType },
})
}
describe('PlatformTypeBadge ChatGPT plan tiers', () => {
it('labels pro / chatgptpro as Pro 20x with the Pro color', () => {
for (const planType of ['pro', 'chatgptpro', 'PRO']) {
const wrapper = mountPlan('openai', planType)
expect(wrapper.text()).toContain('Pro 20x')
expect(wrapper.html()).toContain('bg-violet-100')
}
})
it('labels prolite as Pro 5x 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.html()).toContain('bg-violet-100')
expect(wrapper.text()).not.toContain('Pro 20x')
}
})
it('labels team as Business Standard with the Team color', () => {
const wrapper = mountPlan('openai', 'team')
expect(wrapper.text()).toContain('Business Standard')
expect(wrapper.html()).toContain('bg-indigo-100')
})
it('labels self_serve_business_prolite as Business Premium sharing the Team color', () => {
for (const planType of ['self_serve_business_prolite', 'selfservebusinessprolite']) {
const wrapper = mountPlan('openai', planType)
expect(wrapper.text()).toContain('Business Premium')
expect(wrapper.html()).toContain('bg-indigo-100')
expect(wrapper.text()).not.toContain('self_serve_business_prolite')
}
})
it('keeps plus, free and abnormal labels unchanged', () => {
const plus = mountPlan('openai', 'plus')
expect(plus.text()).toContain('Plus')
expect(plus.html()).toContain('bg-sky-100')
const free = mountPlan('openai', 'free')
expect(free.text()).toContain('Free')
expect(free.html()).toContain('bg-gray-100')
const abnormal = mountPlan('openai', 'abnormal')
expect(abnormal.text()).toContain('admin.accounts.subscriptionAbnormal')
expect(abnormal.html()).toContain('bg-red-100')
})
it('falls back to the raw value for unknown plans', () => {
const wrapper = mountPlan('openai', 'enterprise')
expect(wrapper.text()).toContain('enterprise')
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。
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')
}
const antigravityTeam = mountPlan('antigravity', 'team')
expect(antigravityTeam.text()).toContain('Team')
expect(antigravityTeam.text()).not.toContain('Business Standard')
})
})
+43
View File
@@ -0,0 +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`。
*/
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'
case 'team':
return 'Business Standard'
case 'free':
return 'Free'
default:
return ''
}
}