mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 00:27: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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user