feat: 支持 OpenAI 重置卡按用量阈值自动使用

This commit is contained in:
NellPoi
2026-08-24 13:28:33 +08:00
parent 7075ae0d82
commit 6f972145b7
31 changed files with 1906 additions and 66 deletions
@@ -2266,6 +2266,66 @@
</div>
</div>
<div
v-if="account?.platform === 'openai' && account?.type === 'oauth' && !isSparkShadow"
class="space-y-4 border-t border-gray-200 pt-4 dark:border-dark-600"
data-testid="auto-reset-credit-settings"
>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<label class="input-label mb-0">{{ t('admin.accounts.autoResetCredit.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.autoResetCredit.hint') }}
</p>
</div>
<button
type="button"
data-testid="auto-reset-credit-enabled"
@click="autoResetCreditEnabled = !autoResetCreditEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
autoResetCreditEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
autoResetCreditEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.autoResetCredit.threshold5h') }}</label>
<input
v-model.number="autoResetCredit5hThreshold"
type="number"
min="0.1"
max="100"
step="0.1"
class="input"
:disabled="!autoResetCreditEnabled"
data-testid="auto-reset-credit-5h-threshold"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.autoResetCredit.threshold7d') }}</label>
<input
v-model.number="autoResetCredit7dThreshold"
type="number"
min="0.1"
max="100"
step="0.1"
class="input"
:disabled="!autoResetCreditEnabled"
data-testid="auto-reset-credit-7d-threshold"
/>
</div>
</div>
<p class="input-hint">{{ t('admin.accounts.autoResetCredit.thresholdHint') }}</p>
</div>
<!-- 配额控制 (Anthropic OAuth/SetupToken: 亲和 + 窗口费用 + 会话 + RPM 等) -->
<div
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')"
@@ -3098,6 +3158,9 @@ const autoPause5hThreshold = ref<number | null>(null)
const autoPause7dThreshold = ref<number | null>(null)
const autoPause5hDisabled = ref(false)
const autoPause7dDisabled = ref(false)
const autoResetCreditEnabled = ref(false)
const autoResetCredit5hThreshold = ref(100)
const autoResetCredit7dThreshold = ref(100)
const upstreamBillingAutoProbeEnabled = ref(false)
const upstreamBillingRateSyncEnabled = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
@@ -3626,6 +3689,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null
autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true
autoPause7dDisabled.value = extra?.auto_pause_7d_disabled === true
autoResetCreditEnabled.value = extra?.auto_reset_credit_enabled === true
autoResetCredit5hThreshold.value =
typeof extra?.auto_reset_credit_5h_threshold === 'number' ? extra.auto_reset_credit_5h_threshold * 100 : 100
autoResetCredit7dThreshold.value =
typeof extra?.auto_reset_credit_7d_threshold === 'number' ? extra.auto_reset_credit_7d_threshold * 100 : 100
upstreamBillingAutoProbeEnabled.value = extra?.upstream_billing_probe_enabled === true
upstreamBillingRateSyncEnabled.value =
upstreamBillingAutoProbeEnabled.value && extra?.upstream_billing_rate_sync_enabled === true
@@ -4518,6 +4586,13 @@ const handleSubmit = async () => {
appStore.showError(t('admin.accounts.pleaseSelectStatus'))
return
}
if (autoResetCreditEnabled.value) {
const thresholds = [autoResetCredit5hThreshold.value, autoResetCredit7dThreshold.value]
if (thresholds.some((value) => !Number.isFinite(value) || value < 0.1 || value > 100)) {
appStore.showError(t('admin.accounts.autoResetCredit.thresholdInvalid'))
return
}
}
const updatePayload: Record<string, unknown> = { ...form }
try {
@@ -5082,6 +5157,13 @@ const handleSubmit = async () => {
} else {
delete newExtra.auto_pause_7d_disabled
}
if (props.account.type === 'oauth' && !isSparkShadow.value) {
newExtra.auto_reset_credit_enabled = autoResetCreditEnabled.value
newExtra.auto_reset_credit_5h_threshold = autoResetCredit5hThreshold.value / 100
newExtra.auto_reset_credit_7d_threshold = autoResetCredit7dThreshold.value / 100
}
// 运行态只允许后端服务更新,账号编辑不得回写旧状态。
delete newExtra.codex_auto_reset_credit_state
delete newExtra.codex_image_generation_bridge_enabled
switch (codexImageToolMode.value) {
@@ -63,6 +63,32 @@
</button>
</div>
<div
v-if="autoResetState"
class="flex flex-wrap items-center gap-1 text-[10px]"
data-testid="auto-reset-credit-state"
>
<span
class="inline-flex items-center rounded px-1.5 py-0.5 font-medium"
:class="autoResetStateClass"
>
{{ autoResetStateLabel }}
<span v-if="autoResetState.trigger_window" class="ml-1 tabular-nums">
{{ autoResetState.trigger_window }}
</span>
</span>
<span v-if="autoResetState.checked_at" class="text-gray-500 dark:text-gray-400">
{{ formatResetCreditExpiry(autoResetState.checked_at, 'short') }}
</span>
<span
v-if="autoResetState.error_code"
class="max-w-full truncate text-red-600 dark:text-red-400"
:title="autoResetState.error_code"
>
{{ autoResetState.error_code }}
</span>
</div>
<div v-if="primaryResetCreditExpiry" class="space-y-1">
<div class="flex flex-wrap items-center gap-1">
<span
@@ -172,6 +198,42 @@ const resetWarning = ref<string | null>(null)
const showResetConfirm = ref(false)
const showResetCreditDetails = ref(false)
type AutoResetCreditState = NonNullable<NonNullable<Account['extra']>['codex_auto_reset_credit_state']>
const validAutoResetStatuses = new Set(['checking', 'available', 'resetting', 'success', 'no_credit', 'failed'])
const autoResetState = computed<AutoResetCreditState | null>(() => {
if (props.account.extra?.auto_reset_credit_enabled !== true) return null
const state = props.account.extra?.codex_auto_reset_credit_state
if (!state || typeof state !== 'object' || !validAutoResetStatuses.has(String(state.status))) return null
return state
})
const autoResetStateLabel = computed(() => {
if (!autoResetState.value?.status) return ''
const keyByStatus: Record<string, string> = {
checking: 'checking',
available: 'available',
resetting: 'resetting',
success: 'success',
no_credit: 'noCredit',
failed: 'failed'
}
return t(`admin.accounts.openaiQuotaReset.autoStatus.${keyByStatus[autoResetState.value.status]}`)
})
const autoResetStateClass = computed(() => {
switch (autoResetState.value?.status) {
case 'available':
return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
case 'success':
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
case 'no_credit':
case 'failed':
return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300'
case 'resetting':
return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300'
default:
return 'bg-gray-100 text-gray-600 dark:bg-dark-800 dark:text-gray-300'
}
})
// Rehydrate the card from the persisted snapshot. Credits that already expired
// are dropped and the count is clamped to what remains: the snapshot has no
// freshness signal, so an unfiltered read would offer to consume credits that no
@@ -290,6 +290,18 @@ function buildOpenAISetupTokenAccount() {
} as any
}
function buildOpenAIOAuthParentAccount() {
return {
...buildAccount(),
id: 7,
name: 'OpenAI OAuth Parent',
type: 'oauth',
parent_account_id: null,
credentials: { access_token: 'oauth-token' },
extra: {}
} as any
}
function mountModal(account = buildAccount()) {
return mount(EditAccountModal, {
props: {
@@ -1351,3 +1363,63 @@ describe('EditAccountModal', () => {
)
})
})
describe('EditAccountModal OpenAI 自动使用重置卡', () => {
beforeEach(() => {
authIsSimpleMode.value = true
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
})
it('仅对 OpenAI OAuth 母账号显示,默认关闭且阈值为 100/100', () => {
const parent = mountModal(buildOpenAIOAuthParentAccount())
expect(parent.find('[data-testid="auto-reset-credit-settings"]').exists()).toBe(true)
expect((parent.get('[data-testid="auto-reset-credit-5h-threshold"]').element as HTMLInputElement).value).toBe('100')
expect((parent.get('[data-testid="auto-reset-credit-7d-threshold"]').element as HTMLInputElement).value).toBe('100')
expect(parent.get('[data-testid="auto-reset-credit-5h-threshold"]').attributes('disabled')).toBeDefined()
parent.unmount()
for (const account of [buildAccount(), buildOpenAISetupTokenAccount(), buildOpenAISparkShadowAccount()]) {
const wrapper = mountModal(account)
expect(wrapper.find('[data-testid="auto-reset-credit-settings"]').exists()).toBe(false)
wrapper.unmount()
}
})
it('独立保存两个阈值,并禁止把运行态回写到管理请求', async () => {
const account = buildOpenAIOAuthParentAccount()
account.extra = {
codex_auto_reset_credit_state: {
status: 'success',
trigger_window: '5h',
available_count: 1
}
}
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="auto-reset-credit-enabled"]').trigger('click')
await wrapper.get('[data-testid="auto-reset-credit-5h-threshold"]').setValue('75.5')
await wrapper.get('[data-testid="auto-reset-credit-7d-threshold"]').setValue('92')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
const extra = updateAccountMock.mock.calls[0]?.[1]?.extra
expect(extra).toMatchObject({
auto_reset_credit_enabled: true,
auto_reset_credit_5h_threshold: 0.755,
auto_reset_credit_7d_threshold: 0.92
})
expect(extra).not.toHaveProperty('codex_auto_reset_credit_state')
wrapper.unmount()
})
it('开启后拒绝超出 0.1–100 范围的任一阈值', async () => {
const wrapper = mountModal(buildOpenAIOAuthParentAccount())
await wrapper.get('[data-testid="auto-reset-credit-enabled"]').trigger('click')
await wrapper.get('[data-testid="auto-reset-credit-5h-threshold"]').setValue('0')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -344,3 +344,45 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
wrapper.unmount()
})
})
describe('OpenAIQuotaResetCell 自动用卡运行态', () => {
it.each([
['checking', 'checking'],
['available', 'available'],
['resetting', 'resetting'],
['success', 'success'],
['no_credit', 'noCredit'],
['failed', 'failed'],
] as const)('展示 %s 状态且不需要暴露卡标识', (status, labelKey) => {
const account = makeAccount({
extra: {
auto_reset_credit_enabled: true,
codex_auto_reset_credit_state: {
status,
trigger_window: '5h',
available_count: 1,
checked_at: '2099-07-03T04:05:06Z',
error_code: status === 'failed' ? 'RESET_FAILED' : undefined,
},
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
const state = wrapper.get('[data-testid="auto-reset-credit-state"]')
expect(state.text()).toContain(`admin.accounts.openaiQuotaReset.autoStatus.${labelKey}`)
expect(state.text()).toContain('5h')
expect(state.text()).not.toContain('credit_id')
wrapper.unmount()
})
it('开关关闭时不显示历史运行态', () => {
const account = makeAccount({
extra: {
auto_reset_credit_enabled: false,
codex_auto_reset_credit_state: { status: 'success', available_count: 1 },
},
})
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
expect(wrapper.find('[data-testid="auto-reset-credit-state"]').exists()).toBe(false)
wrapper.unmount()
})
})