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:
Brisbanehuang
2026-08-01 22:11:09 +08:00
committed by shaw
parent 56f3d3c9b0
commit b0f5007f04
34 changed files with 1382 additions and 146 deletions
@@ -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' }) })