mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 16:29:29 +00:00
feat: 支持 OpenAI 重置卡按用量阈值自动使用
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user