mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
feat(billing-probe): optionally sync account rate from upstream declared rate
Successful upstream billing probes already persist the upstream-declared rate as a display-only snapshot. Add a per-account opt-in that writes that declared rate back to the account's rate_multiplier, so the account cost basis follows upstream repricing automatically instead of drifting until an operator notices. - new per-account flag upstream_billing_rate_sync_enabled stored next to the probe flag in account extra: enabling sync force-enables the probe, disabling the probe cascades sync off, and eligibility follows IsUpstreamBillingProbeIdentity (tightened from any non-empty platform to an explicit whitelist of the five supported API-key platforms so future platforms do not silently inherit probe/sync semantics) - only a successful probe whose declared rate survives validation (finite, within bounds, not rounded to zero at the rate_multiplier decimal(10,4) scale) writes back; failed/unsupported/invalid probes leave rate_multiplier unchanged - the writeback rides the existing snapshot CAS transaction: UpdateUpstreamBillingProbeSnapshot takes an optional rateMultiplier and applies it atomically with the snapshot under the same identity/snapshot compare-and-swap, so a probe result observed on a stale account cannot clobber a concurrent admin edit - admin edit goes through UpdateWithAccountBillingSettings, which applies the form without overwriting a rate that a probe synchronized after the edit form was loaded (nil rateMultiplier = not edited); once sync is enabled the edit form shows the rate as managed - bulk update rejects a manual rate_multiplier change when any target account has rate sync enabled (whole batch fails with a dedicated error so partial writes cannot bypass the sync ownership) - frontend: sync toggle with hints in the edit modal (probe/sync enable/disable coupling enforced in the form), synced-rate tooltip on the rate cell, bulk edit modal warns and blocks rate edits that hit sync-enabled accounts; en/zh copy updated - tests: service unit tests for sync gating/validation/cascade, sqlmock repo tests for the extended CAS, real-PostgreSQL integration tests (rate written only for successful+enabled accounts, manual rate protected after sync disabled, admin edit preserved across concurrent probe sync), handler/API contract updates, frontend specs for modal coupling, bulk rejection and rate cell
This commit is contained in:
@@ -750,6 +750,14 @@
|
||||
aria-labelledby="bulk-edit-rate-multiplier-label"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||||
<p
|
||||
v-if="enableRateMultiplier"
|
||||
class="mt-2 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-300"
|
||||
data-testid="bulk-rate-sync-warning"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" class="mt-0.5 flex-shrink-0" />
|
||||
<span>{{ t('admin.accounts.bulkEdit.rateSyncWarning') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1985,6 +1993,10 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
|
||||
pendingUpdatesForConfirm.value = baseUpdates
|
||||
mixedChannelWarningMessage.value = error.message
|
||||
showMixedChannelWarning.value = true
|
||||
} else if (error.reason === 'UPSTREAM_BILLING_RATE_SYNC_BULK_CONFLICT') {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.rateSyncConflict', {
|
||||
count: error.metadata?.count ?? 1
|
||||
}))
|
||||
} else {
|
||||
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
|
||||
console.error('Error bulk updating accounts:', error)
|
||||
|
||||
@@ -1441,8 +1441,43 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.001" class="input" />
|
||||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||||
<input
|
||||
v-model.number="form.rate_multiplier"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
class="input disabled:cursor-not-allowed disabled:opacity-60"
|
||||
data-testid="account-rate-multiplier"
|
||||
:disabled="upstreamBillingRateSyncEnabled"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
{{
|
||||
t(
|
||||
upstreamBillingRateSyncEnabled
|
||||
? 'admin.accounts.upstreamBilling.syncRateManagedHint'
|
||||
: 'admin.accounts.billingRateMultiplierHint'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
v-if="account?.type === 'apikey'"
|
||||
class="mt-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ t('admin.accounts.upstreamBilling.syncRate') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.upstreamBilling.syncRateHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="upstreamBillingRateSyncEnabled"
|
||||
data-testid="upstream-billing-rate-sync"
|
||||
:aria-label="t('admin.accounts.upstreamBilling.syncRate')"
|
||||
@update:model-value="handleUpstreamBillingRateSyncChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
@@ -1660,9 +1695,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="upstreamBillingAutoProbeEnabled"
|
||||
:model-value="upstreamBillingAutoProbeEnabled"
|
||||
data-testid="upstream-billing-auto-probe"
|
||||
:aria-label="t('admin.accounts.upstreamBilling.autoProbe')"
|
||||
@update:model-value="handleUpstreamBillingAutoProbeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2822,6 +2858,7 @@ const autoPause7dThreshold = ref<number | null>(null)
|
||||
const autoPause5hDisabled = ref(false)
|
||||
const autoPause7dDisabled = ref(false)
|
||||
const upstreamBillingAutoProbeEnabled = ref(false)
|
||||
const upstreamBillingRateSyncEnabled = ref(false)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
|
||||
const antigravityProjectId = ref('')
|
||||
@@ -3191,6 +3228,20 @@ const form = reactive({
|
||||
expires_at: null as number | null
|
||||
})
|
||||
|
||||
const handleUpstreamBillingRateSyncChange = (enabled: boolean) => {
|
||||
upstreamBillingRateSyncEnabled.value = enabled
|
||||
if (enabled) {
|
||||
upstreamBillingAutoProbeEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpstreamBillingAutoProbeChange = (enabled: boolean) => {
|
||||
upstreamBillingAutoProbeEnabled.value = enabled
|
||||
if (!enabled) {
|
||||
upstreamBillingRateSyncEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
const options = [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
@@ -3306,6 +3357,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true
|
||||
autoPause7dDisabled.value = extra?.auto_pause_7d_disabled === true
|
||||
upstreamBillingAutoProbeEnabled.value = extra?.upstream_billing_probe_enabled === true
|
||||
upstreamBillingRateSyncEnabled.value =
|
||||
upstreamBillingAutoProbeEnabled.value && extra?.upstream_billing_rate_sync_enabled === true
|
||||
|
||||
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
|
||||
openaiPassthroughEnabled.value = false
|
||||
@@ -4086,6 +4139,13 @@ const handleSubmit = async () => {
|
||||
updatePayload.load_factor = 0
|
||||
}
|
||||
updatePayload.auto_pause_on_expired = autoPauseOnExpired.value
|
||||
if (props.account.type === 'apikey') {
|
||||
updatePayload.upstream_billing_probe_enabled = upstreamBillingAutoProbeEnabled.value
|
||||
updatePayload.upstream_billing_rate_sync_enabled = upstreamBillingRateSyncEnabled.value
|
||||
if (upstreamBillingRateSyncEnabled.value) {
|
||||
delete updatePayload.rate_multiplier
|
||||
}
|
||||
}
|
||||
|
||||
// For apikey type, handle credentials update
|
||||
if (props.account.type === 'apikey') {
|
||||
@@ -4651,7 +4711,8 @@ const handleSubmit = async () => {
|
||||
// 上游倍率自动探测对全部 API-key 平台开放(sub2api 上游即可应答),
|
||||
// Bedrock 凭证无静态 Key 不参与。
|
||||
if (props.account.type === 'apikey') {
|
||||
newExtra.upstream_billing_probe_enabled = upstreamBillingAutoProbeEnabled.value
|
||||
delete newExtra.upstream_billing_probe_enabled
|
||||
delete newExtra.upstream_billing_rate_sync_enabled
|
||||
}
|
||||
// Total quota
|
||||
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
|
||||
|
||||
@@ -88,6 +88,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatMultiplier } from '@/utils/formatters'
|
||||
import type { Account, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -191,7 +192,7 @@ const elapsedSinceLastSuccess = computed(() => {
|
||||
const effectiveRate = computed(() => {
|
||||
if (!validTimestamps.value || stale.value || !['ok', 'failed'].includes(snapshot.value?.status ?? '')) return '-'
|
||||
const value = currentEffectiveRate.value
|
||||
return value == null ? '-' : `${Number(value.toPrecision(12))}x`
|
||||
return value == null ? '-' : `${formatMultiplier(value)}x`
|
||||
})
|
||||
const statusLabel = computed(() => {
|
||||
if (!snapshot.value) return t('admin.accounts.upstreamBilling.notProbed')
|
||||
|
||||
@@ -4,9 +4,13 @@ import BulkEditAccountModal from '../BulkEditAccountModal.vue'
|
||||
import ModelWhitelistSelector from '../ModelWhitelistSelector.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
const { showError } = vi.hoisted(() => ({
|
||||
showError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showError,
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn()
|
||||
})
|
||||
@@ -77,6 +81,7 @@ describe('BulkEditAccountModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockReset()
|
||||
vi.mocked(adminAPI.accounts.checkMixedChannelRisk).mockReset()
|
||||
showError.mockReset()
|
||||
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValue({
|
||||
success: 2,
|
||||
@@ -88,6 +93,33 @@ describe('BulkEditAccountModal', () => {
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('批量修改倍率时提示自动同步账号需要先关闭同步', async () => {
|
||||
const wrapper = mountModal()
|
||||
|
||||
expect(wrapper.find('[data-testid="bulk-rate-sync-warning"]').exists()).toBe(false)
|
||||
await wrapper.get('#bulk-edit-rate-multiplier-enabled').setValue(true)
|
||||
|
||||
expect(wrapper.get('[data-testid="bulk-rate-sync-warning"]').text()).toContain(
|
||||
'admin.accounts.bulkEdit.rateSyncWarning'
|
||||
)
|
||||
})
|
||||
|
||||
it('后端拒绝修改同步账号倍率时展示专用错误', async () => {
|
||||
vi.mocked(adminAPI.accounts.bulkUpdate).mockRejectedValueOnce({
|
||||
status: 409,
|
||||
reason: 'UPSTREAM_BILLING_RATE_SYNC_BULK_CONFLICT',
|
||||
metadata: { count: '2' },
|
||||
message: 'conflict'
|
||||
})
|
||||
const wrapper = mountModal()
|
||||
|
||||
await wrapper.get('#bulk-edit-rate-multiplier-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.rateSyncConflict')
|
||||
})
|
||||
|
||||
it('antigravity 白名单包含 Gemini 图片模型且过滤掉普通 GPT 模型', async () => {
|
||||
const wrapper = mountModal()
|
||||
const selector = wrapper.findComponent(ModelWhitelistSelector)
|
||||
|
||||
@@ -654,7 +654,10 @@ describe('EditAccountModal', () => {
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.upstream_billing_probe_enabled).toBe(true)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.upstream_billing_probe_enabled).toBe(true)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty(
|
||||
'upstream_billing_probe_enabled'
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes the upstream billing auto-probe toggle for non-OpenAI API-key accounts', async () => {
|
||||
@@ -676,7 +679,92 @@ describe('EditAccountModal', () => {
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.upstream_billing_probe_enabled).toBe(true)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.upstream_billing_probe_enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enabling rate sync also enables probing and stops submitting a manual rate', async () => {
|
||||
const account = buildAccount()
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const syncToggle = wrapper.get('[data-testid="upstream-billing-rate-sync"]')
|
||||
const probeToggle = wrapper.get('[data-testid="upstream-billing-auto-probe"]')
|
||||
const rateInput = wrapper.get<HTMLInputElement>('[data-testid="account-rate-multiplier"]')
|
||||
expect(syncToggle.attributes('aria-checked')).toBe('false')
|
||||
expect(probeToggle.attributes('aria-checked')).toBe('false')
|
||||
expect(rateInput.element.disabled).toBe(false)
|
||||
expect(wrapper.text()).toContain('admin.accounts.billingRateMultiplierHint')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.syncRateManagedHint')
|
||||
|
||||
await syncToggle.trigger('click')
|
||||
expect(syncToggle.attributes('aria-checked')).toBe('true')
|
||||
expect(probeToggle.attributes('aria-checked')).toBe('true')
|
||||
expect(rateInput.element.disabled).toBe(true)
|
||||
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.syncRateManagedHint')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.billingRateMultiplierHint')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.upstream_billing_probe_enabled).toBe(true)
|
||||
expect(payload?.upstream_billing_rate_sync_enabled).toBe(true)
|
||||
expect(payload).not.toHaveProperty('rate_multiplier')
|
||||
})
|
||||
|
||||
it('disabling probing also disables rate sync and restores manual rate editing', async () => {
|
||||
const account = buildAccount()
|
||||
account.extra = {
|
||||
upstream_billing_probe_enabled: true,
|
||||
upstream_billing_rate_sync_enabled: true
|
||||
}
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const syncToggle = wrapper.get('[data-testid="upstream-billing-rate-sync"]')
|
||||
const probeToggle = wrapper.get('[data-testid="upstream-billing-auto-probe"]')
|
||||
const rateInput = wrapper.get<HTMLInputElement>('[data-testid="account-rate-multiplier"]')
|
||||
expect(syncToggle.attributes('aria-checked')).toBe('true')
|
||||
expect(rateInput.element.disabled).toBe(true)
|
||||
|
||||
await probeToggle.trigger('click')
|
||||
expect(probeToggle.attributes('aria-checked')).toBe('false')
|
||||
expect(syncToggle.attributes('aria-checked')).toBe('false')
|
||||
expect(rateInput.element.disabled).toBe(false)
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.upstream_billing_probe_enabled).toBe(false)
|
||||
expect(payload?.upstream_billing_rate_sync_enabled).toBe(false)
|
||||
expect(payload?.rate_multiplier).toBe(1)
|
||||
})
|
||||
|
||||
it('disabling only rate sync keeps automatic probing enabled', async () => {
|
||||
const account = buildAccount()
|
||||
account.extra = {
|
||||
upstream_billing_probe_enabled: true,
|
||||
upstream_billing_rate_sync_enabled: true
|
||||
}
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
await wrapper.get('[data-testid="upstream-billing-rate-sync"]').trigger('click')
|
||||
expect(wrapper.get('[data-testid="upstream-billing-auto-probe"]').attributes('aria-checked')).toBe(
|
||||
'true'
|
||||
)
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.upstream_billing_probe_enabled).toBe(true)
|
||||
expect(payload?.upstream_billing_rate_sync_enabled).toBe(false)
|
||||
expect(payload?.rate_multiplier).toBe(1)
|
||||
})
|
||||
|
||||
it('clears OpenAI APIKey Responses override when set back to auto', async () => {
|
||||
|
||||
@@ -88,11 +88,11 @@ describe('UpstreamBillingRateCell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('0.6x')
|
||||
expect(wrapper.text()).toContain('0.60x')
|
||||
await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00Z') })
|
||||
expect(wrapper.text()).toContain('0.9x')
|
||||
expect(wrapper.text()).toContain('0.90x')
|
||||
await wrapper.setProps({ now: Date.parse('2026-07-13T10:00:00Z') })
|
||||
expect(wrapper.text()).toContain('0.6x')
|
||||
expect(wrapper.text()).toContain('0.60x')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.latest')
|
||||
expect(wrapper.get('[data-testid="upstream-billing-probe"]').text()).toBe('')
|
||||
expect(wrapper.get('[data-testid="upstream-billing-probe"]').attributes('aria-label')).toBe(
|
||||
@@ -132,11 +132,11 @@ describe('UpstreamBillingRateCell', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('0.6x')
|
||||
expect(wrapper.text()).toContain('0.60x')
|
||||
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.failed')
|
||||
|
||||
await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00Z') })
|
||||
expect(wrapper.text()).toContain('0.9x')
|
||||
expect(wrapper.text()).toContain('0.90x')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.stale')
|
||||
|
||||
await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00.001Z') })
|
||||
@@ -312,7 +312,7 @@ describe('UpstreamBillingRateCell', () => {
|
||||
await wrapper.setProps({ account: malformedAccount({}, { received_at: 'not-a-time' }) })
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale')
|
||||
await wrapper.setProps({ account: malformedAccount({}, { received_at: '2026-07-13T00:31:00Z' }) })
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('0.6x')
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('0.60x')
|
||||
await wrapper.setProps({ account: malformedAccount({}, { received_at: '2026-07-13T00:36:00Z' }) })
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale')
|
||||
await wrapper.setProps({ account: malformedAccount({}, { fresh_until: '2026-07-12T23:59:00Z' }) })
|
||||
|
||||
Reference in New Issue
Block a user