diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 9b5ab82fc5..9984d2bc48 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -1838,6 +1838,24 @@
+
+
+
+
+
+
+ {{ t('admin.accounts.openai.planTypeDesc') }}
+
+
+
+
+
+
+
+
('')
const openAICompactMode = ref('auto')
const openAIResponsesMode = ref('auto')
const openAIEndpointCapabilities = ref(['chat_completions', 'embeddings'])
@@ -2884,6 +2907,10 @@ const openAICompactModeOptions = computed(() => [
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
{ value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
])
+// OpenAI 订阅档位手动覆盖选项(清空 + Plus/Pro/Free;别名/自定义值友好显示且保留 canonical)
+const planTypeOptions = computed(() =>
+ buildPlanTypeOptions(editPlanType.value, t('admin.accounts.openai.planTypeClear'))
+)
const openAIResponsesModeOptions = computed(() => [
{ value: 'auto', label: t('admin.accounts.openai.responsesModeAuto') },
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
@@ -3183,6 +3210,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
openaiPassthroughEnabled.value = false
+ editPlanType.value = ''
openAICompactMode.value = 'auto'
openAIResponsesMode.value = 'auto'
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
@@ -3197,6 +3225,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
+ // plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填
+ editPlanType.value = newAccount.type === 'oauth'
+ ? readPlanType(newAccount.credentials as Record | undefined)
+ : ''
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
if (newAccount.type === 'apikey') {
openAIResponsesMode.value = normalizeOpenAIResponsesMode(extra?.openai_responses_mode)
@@ -4175,6 +4207,14 @@ const handleSubmit = async () => {
updatePayload.credentials = newCredentials
}
+ // OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
+ // 影子账号凭据由母账号管理(且后端会 sanitize),setup-token 无订阅调度语义。
+ if (props.account.platform === 'openai' && props.account.type === 'oauth' && !isSparkShadow.value) {
+ const currentCredentials = (updatePayload.credentials as Record) ||
+ ((props.account.credentials as Record) || {})
+ updatePayload.credentials = applyPlanType({ ...currentCredentials }, editPlanType.value)
+ }
+
// Antigravity: persist model mapping to credentials (applies to all antigravity types)
// Antigravity 只支持映射模式
if (props.account.platform === 'antigravity') {
diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
index c2cb093805..ae4c6739a8 100644
--- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
+++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
@@ -6,9 +6,13 @@ import {
applyAntigravityProjectID,
applyHeaderOverride,
applyInterceptWarmup,
+ applyPlanType,
buildHeaderOverridesObject,
+ buildPlanTypeOptions,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
+ planTypeDisplayLabel,
+ readPlanType,
splitHeaderOverridesObject,
validateHeaderOverrideRows
} from '../credentialsBuilder'
@@ -289,3 +293,88 @@ describe('validateHeaderOverrideRows session isolation headers', () => {
expect(validateHeaderOverrideRows([{ name: 'x'.repeat(201), value: 'v' }])).toBe('invalidName')
})
})
+
+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('free')).toBe('Free')
+ expect(planTypeDisplayLabel('team')).toBe('Team')
+ expect(planTypeDisplayLabel('CHATGPTPRO')).toBe('Pro')
+ })
+ it('returns unknown values verbatim', () => {
+ expect(planTypeDisplayLabel('self_serve_business')).toBe('self_serve_business')
+ })
+ })
+
+ describe('readPlanType', () => {
+ it('reads a string plan_type', () => {
+ expect(readPlanType({ plan_type: 'plus' })).toBe('plus')
+ })
+ it('treats non-string / missing values as empty', () => {
+ expect(readPlanType({ plan_type: 42 })).toBe('')
+ expect(readPlanType({ plan_type: true })).toBe('')
+ expect(readPlanType({})).toBe('')
+ expect(readPlanType(undefined)).toBe('')
+ expect(readPlanType(null)).toBe('')
+ })
+ })
+
+ 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' },
+ { value: 'free', label: 'Free' }
+ ])
+ })
+ it('keeps canonical chatgptpro under a single friendly "Pro" option (no duplicate)', () => {
+ const opts = buildPlanTypeOptions('chatgptpro', clear)
+ const pros = opts.filter(o => o.label === 'Pro')
+ expect(pros).toHaveLength(1)
+ expect(pros[0].value).toBe('chatgptpro')
+ expect(opts.map(o => o.value)).toEqual(['', 'plus', 'chatgptpro', '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' })
+ // presets untouched
+ expect(opts.map(o => o.value)).toEqual(['', 'plus', 'pro', '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', 'free'])
+ })
+ })
+
+ describe('applyPlanType', () => {
+ it('sets plan_type and preserves all other credential keys', () => {
+ const creds = {
+ chatgpt_account_id: 'acc',
+ email: 'a@b.c',
+ subscription_expires_at: '2026-01-01',
+ model_mapping: { x: 'y' }
+ }
+ const out = applyPlanType({ ...creds }, 'plus')
+ expect(out).toEqual({ ...creds, plan_type: 'plus' })
+ })
+ it('trims the value', () => {
+ expect(applyPlanType({}, ' pro ')).toEqual({ plan_type: 'pro' })
+ })
+ it('deletes the key when cleared (empty), keeping other keys', () => {
+ const out = applyPlanType({ plan_type: 'pro', email: 'a@b.c' }, '')
+ expect(out).toEqual({ email: 'a@b.c' })
+ expect('plan_type' in out).toBe(false)
+ })
+ })
+})
+
diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts
index 3cdc0bdd54..e78cf41de6 100644
--- a/frontend/src/components/account/credentialsBuilder.ts
+++ b/frontend/src/components/account/credentialsBuilder.ts
@@ -201,3 +201,87 @@ export function applyHeaderOverride(
delete credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
}
}
+
+// ===== OpenAI plan_type (ChatGPT 订阅档位) 手动覆盖 =====
+
+export interface PlanTypeOption {
+ value: string
+ label: string
+ // 兼容 common/Select.vue 的 SelectOption(含索引签名)
+ [key: string]: unknown
+}
+
+/**
+ * plan_type 值的友好显示标签,镜像 PlatformTypeBadge 的映射
+ * (canonical 值 chatgptpro 显示为 Pro,team 显示为 Team)。未知值原样返回。
+ */
+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
+ }
+}
+
+/**
+ * 从凭据里读取 plan_type,仅接受字符串(脏数据 42/true 等一律视为空,
+ * 避免被当作合法自定义项保留)。
+ */
+export function readPlanType(credentials: Record | undefined | null): string {
+ const v = credentials?.plan_type
+ return typeof v === 'string' ? v : ''
+}
+
+/**
+ * 构建 plan_type 下拉选项:清空 + Plus/Pro/Free 预设。
+ * 若当前值是某预设的别名(如 chatgptpro↔Pro),用当前的 canonical 值占据该
+ * 标签位(保留 canonical,显示友好标签,避免重复项);若是完全预设外的值
+ * (如 team 或异常值),追加为一项,避免编辑时下拉丢失原值。
+ */
+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' },
+ { value: 'free', label: 'Free' }
+ ]
+ 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)
+ }
+ }
+ if (cur && !opts.some(o => o.value.toLowerCase() === cur.toLowerCase())) {
+ opts.push({ value: cur, label: planTypeDisplayLabel(cur) })
+ }
+ return opts
+}
+
+/**
+ * 把手动选择的 plan_type 写入凭据:非空则设置,空则删除该键(清空/自动识别)。
+ * 直接修改传入对象并返回。
+ */
+export function applyPlanType(
+ credentials: Record,
+ planType: string
+): Record {
+ const pt = (planType || '').trim()
+ if (pt) {
+ credentials.plan_type = pt
+ } else {
+ delete credentials.plan_type
+ }
+ return credentials
+}
diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts
index 57e2bbf327..14fb2eca57 100644
--- a/frontend/src/i18n/locales/en/admin/accounts.ts
+++ b/frontend/src/i18n/locales/en/admin/accounts.ts
@@ -446,6 +446,10 @@ export default {
responsesStatusAutoUnknown: 'Auto probe: unknown',
responsesStatusForcedResponses: 'Forced Responses',
responsesStatusForcedChatCompletions: 'Forced Chat Completions',
+ planType: 'Plan tier (manual override)',
+ planTypeDesc:
+ "Manually correct this account's ChatGPT plan tier (Plus / Pro / Free). Note: a token refresh near expiry or a 429 rate-limit response will auto-overwrite this with the real tier.",
+ planTypeClear: 'Clear (auto-detect)',
codexCLIOnly: 'Codex official clients only',
codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts
index 6f6c721e83..1913946266 100644
--- a/frontend/src/i18n/locales/zh/admin/accounts.ts
+++ b/frontend/src/i18n/locales/zh/admin/accounts.ts
@@ -546,6 +546,9 @@ export default {
responsesStatusAutoUnknown: '自动探测:未探测',
responsesStatusForcedResponses: '已强制 Responses',
responsesStatusForcedChatCompletions: '已强制 Chat Completions',
+ planType: '订阅档位(手动覆盖)',
+ planTypeDesc: '手动纠正本账号的 ChatGPT 订阅档位(Plus / Pro / Free)。注意:令牌临期刷新或命中 429 限流时,会用真实档位自动覆盖此处设置。',
+ planTypeClear: '清空(自动识别)',
codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
codexCLIOnlyAppServer: '允许 Codex app-server 客户端',