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
+21 -4
View File
@@ -342,8 +342,17 @@
</div>
</template>
<template #cell-rate_multiplier="{ row }">
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
<span class="inline-flex items-center gap-1 text-sm font-mono text-gray-700 dark:text-gray-300">
<span>{{ formatMultiplier(row.rate_multiplier ?? 1) }}x</span>
<span
v-if="row.extra?.upstream_billing_rate_sync_enabled === true"
class="inline-flex cursor-help text-emerald-600 dark:text-emerald-400"
:aria-label="t('admin.accounts.upstreamBilling.syncedRateTooltip')"
:title="t('admin.accounts.upstreamBilling.syncedRateTooltip')"
data-testid="account-rate-sync-indicator"
>
<Icon name="sync" size="xs" />
</span>
</span>
</template>
<template #header-upstream_billing_rate="{ column }">
@@ -516,6 +525,7 @@ import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
import { extractApiErrorMessage } from '@/utils/apiError'
import { sanitizeUrl } from '@/utils/url'
import { getFloatingPanelPosition } from '@/utils/floatingPanel'
import { formatMultiplier } from '@/utils/formatters'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
const { t } = useI18n()
@@ -1591,7 +1601,7 @@ const handleBulkProbeUpstreamBilling = async () => {
patched = true
}
})
if (patched) await refreshUpstreamBillingSortedList(true)
if (patched) await refreshAccountsAfterUpstreamBillingProbe()
const failed = results.filter(result => result.error).length
if (failed > 0) {
appStore.showError(t('admin.accounts.upstreamBilling.batchPartial', { success: results.length - failed, failed }))
@@ -1890,6 +1900,13 @@ const patchUpstreamBillingSnapshot = (accountID: number, snapshot: UpstreamBilli
extra: { ...account.extra, upstream_billing_probe: snapshot }
})
}
const refreshAccountsAfterUpstreamBillingProbe = async () => {
try {
await load()
} catch (error) {
console.error('Failed to refresh accounts after upstream billing probe:', error)
}
}
const handleProbeUpstreamBilling = async (account: Account) => {
if (probingUpstreamBilling.has(account.id)) return
probingUpstreamBilling.add(account.id)
@@ -1897,7 +1914,7 @@ const handleProbeUpstreamBilling = async (account: Account) => {
const result = await adminAPI.accounts.probeUpstreamBilling(account.id)
if (result.snapshot) {
patchUpstreamBillingSnapshot(account.id, result.snapshot)
await refreshUpstreamBillingSortedList(true)
await refreshAccountsAfterUpstreamBillingProbe()
}
} catch (error) {
console.error('Failed to probe upstream billing:', error)
@@ -10,7 +10,10 @@ const {
getUpstreamBillingProbeSettings,
getAllProxies,
getAllGroups,
probeUpstreamBillingBatch
probeUpstreamBilling,
probeUpstreamBillingBatch,
showError,
showSuccess
} = vi.hoisted(() => ({
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
@@ -18,7 +21,10 @@ const {
getUpstreamBillingProbeSettings: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn(),
probeUpstreamBillingBatch: vi.fn()
probeUpstreamBilling: vi.fn(),
probeUpstreamBillingBatch: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn()
}))
vi.mock('@/api/admin', () => ({
@@ -31,6 +37,7 @@ vi.mock('@/api/admin', () => ({
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
probeUpstreamBilling,
probeUpstreamBillingBatch,
toggleSchedulable: vi.fn()
},
@@ -45,8 +52,8 @@ vi.mock('@/api/admin', () => ({
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showError,
showSuccess,
showInfo: vi.fn()
})
}))
@@ -75,6 +82,19 @@ const DataTableStub = {
<div v-for="row in data" :key="row.id">
<div data-test="select-row"><slot name="cell-select" :row="row" /></div>
<slot name="cell-created_at" :value="row.created_at" :row="row" />
<div data-test="account-rate"><slot name="cell-rate_multiplier" :row="row" /></div>
</div>
</div>
`
}
const ProbeDataTableStub = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.id">
<div data-test="account-rate"><slot name="cell-rate_multiplier" :row="row" /></div>
<slot name="cell-upstream_billing_rate" :row="row" />
</div>
</div>
`
@@ -111,7 +131,10 @@ describe('admin AccountsView bulk edit scope', () => {
getUpstreamBillingProbeSettings.mockReset()
getAllProxies.mockReset()
getAllGroups.mockReset()
probeUpstreamBilling.mockReset()
probeUpstreamBillingBatch.mockReset()
showError.mockReset()
showSuccess.mockReset()
listAccounts.mockResolvedValue({
items: [],
@@ -129,6 +152,7 @@ describe('admin AccountsView bulk edit scope', () => {
getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30 })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
probeUpstreamBilling.mockResolvedValue({})
probeUpstreamBillingBatch.mockResolvedValue([])
})
@@ -376,33 +400,110 @@ describe('admin AccountsView bulk edit scope', () => {
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7, 11])
})
it('reloads the server-sorted list after a batch probe changes a snapshot', async () => {
localStorage.setItem('account-table-sort', JSON.stringify({ key: 'upstream_billing_rate', order: 'asc' }))
const account = (id: number) => ({
it('refreshes the current page after a batch probe and displays the synced rate', async () => {
const account = (id: number, rateMultiplier: number) => ({
id,
name: `account-${id}`,
platform: 'openai',
type: 'apikey',
status: 'active',
schedulable: true,
rate_multiplier: rateMultiplier,
created_at: '2026-07-13T00:00:00Z',
updated_at: '2026-07-13T00:00:00Z'
})
listAccounts
.mockResolvedValueOnce({ items: [account(7)], total: 1, page: 1, page_size: 20, pages: 1 })
.mockResolvedValueOnce({ items: [account(7)], total: 1, page: 1, page_size: 20, pages: 1 })
.mockResolvedValueOnce({ items: [account(7, 0.25)], total: 2, page: 1, page_size: 1, pages: 2 })
.mockResolvedValueOnce({ items: [account(11, 0.25)], total: 2, page: 2, page_size: 1, pages: 2 })
.mockResolvedValueOnce({ items: [account(11, 0.065)], total: 2, page: 2, page_size: 1, pages: 2 })
probeUpstreamBillingBatch.mockResolvedValue([
{
account_id: 7,
account_id: 11,
snapshot: {
status: 'ok',
data: { effective_rate_multiplier: 0.5 },
data: { effective_rate_multiplier: 0.065 },
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T00:30:00Z'
}
}
])
const wrapper = mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: { template: '<div><slot name="table" /><slot name="pagination" /></div>' },
DataTable: DataTableStub,
AccountBulkActionsBar: AccountBulkActionsBarStub,
AccountTableActions: true,
AccountTableFilters: true,
AccountActionMenu: true,
Pagination: PaginationStub,
ConfirmDialog: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: BulkEditAccountModalStub,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
await flushPromises()
await wrapper.get('[data-test="next-page"]').trigger('click')
await flushPromises()
await wrapper.get('[data-test="select-row"] input').trigger('change')
await wrapper.get('[data-test="probe-upstream-billing"]').trigger('click')
await flushPromises()
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([11])
expect(listAccounts).toHaveBeenCalledTimes(3)
expect(listAccounts.mock.calls[2]?.[0]).toBe(2)
expect(wrapper.get('[data-test="account-rate"]').text()).toBe('0.065x')
})
it('does not report a successful batch probe as failed when the list refresh fails', async () => {
const account = {
id: 7,
name: 'account-7',
platform: 'openai',
type: 'apikey',
status: 'active',
schedulable: true,
rate_multiplier: 0.25,
created_at: '2026-07-13T00:00:00Z',
updated_at: '2026-07-13T00:00:00Z'
}
listAccounts
.mockResolvedValueOnce({ items: [account], total: 1, page: 1, page_size: 20, pages: 1 })
.mockRejectedValueOnce(new Error('refresh failed'))
probeUpstreamBillingBatch.mockResolvedValue([
{
account_id: 7,
snapshot: {
status: 'ok',
data: { effective_rate_multiplier: 0.065 },
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T00:30:00Z'
}
}
])
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = mount(AccountsView, {
global: {
stubs: {
@@ -443,7 +544,78 @@ describe('admin AccountsView bulk edit scope', () => {
await wrapper.get('[data-test="probe-upstream-billing"]').trigger('click')
await flushPromises()
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7])
expect(showError).not.toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.upstreamBilling.batchCompleted')
consoleError.mockRestore()
})
it('refreshes the account row after a successful single-account probe', async () => {
const account = (rateMultiplier: number) => ({
id: 7,
name: 'account-7',
platform: 'openai',
type: 'apikey',
status: 'active',
schedulable: true,
rate_multiplier: rateMultiplier,
extra: { upstream_billing_probe_enabled: true },
created_at: '2026-07-13T00:00:00Z',
updated_at: '2026-07-13T00:00:00Z'
})
listAccounts
.mockResolvedValueOnce({ items: [account(0.25)], total: 1, page: 1, page_size: 20, pages: 1 })
.mockResolvedValueOnce({ items: [account(0.065)], total: 1, page: 1, page_size: 20, pages: 1 })
probeUpstreamBilling.mockResolvedValue({
account_id: 7,
snapshot: {
status: 'ok',
data: { effective_rate_multiplier: 0.065 },
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T00:30:00Z'
}
})
const wrapper = mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: { template: '<div><slot name="table" /></div>' },
DataTable: ProbeDataTableStub,
AccountBulkActionsBar: true,
AccountTableActions: true,
AccountTableFilters: true,
AccountActionMenu: true,
Pagination: true,
ConfirmDialog: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: true,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
await flushPromises()
await wrapper.get('[data-testid="upstream-billing-probe"]').trigger('click')
await flushPromises()
expect(probeUpstreamBilling).toHaveBeenCalledWith(7)
expect(listAccounts).toHaveBeenCalledTimes(2)
expect(wrapper.get('[data-test="account-rate"]').text()).toBe('0.065x')
})
})
@@ -75,6 +75,9 @@ const DataTableStub = {
<slot :name="'header-' + column.key" :column="column" />
</div>
</template>
<div v-for="row in data" :key="row.id" data-test="account-rate">
<slot name="cell-rate_multiplier" :row="row" />
</div>
</div>
`
}
@@ -189,4 +192,35 @@ describe('admin AccountsView usage windows hint', () => {
const columns = wrapper.getComponent(DataTableStub).props('columns') as Array<{ key: string; sortable: boolean }>
expect(columns.find(column => column.key === 'upstream_billing_rate')?.sortable).toBe(true)
})
it('shows account multipliers with enough precision to match declared rates', async () => {
listAccounts.mockResolvedValueOnce({
items: [{
id: 7,
name: 'precision-account',
platform: 'gemini',
type: 'apikey',
status: 'active',
schedulable: true,
rate_multiplier: 0.065,
extra: {
upstream_billing_probe_enabled: true,
upstream_billing_rate_sync_enabled: true
},
created_at: '2026-07-13T00:00:00Z',
updated_at: '2026-07-13T00:00:00Z'
}],
total: 1,
page: 1,
page_size: 20,
pages: 1
})
const wrapper = mountView()
await flushPromises()
expect(wrapper.get('[data-test="account-rate"]').text()).toBe('0.065x')
const indicator = wrapper.get('[data-testid="account-rate-sync-indicator"]')
expect(indicator.attributes('title')).toBe('admin.accounts.upstreamBilling.syncedRateTooltip')
})
})