Merge pull request #4385 from yardbirds0/feat/upstream-billing-probe

feat: 增加上游 Sub2API 计费倍率探测与账号展示
This commit is contained in:
Wesley Liddick
2026-07-16 10:27:45 +08:00
committed by GitHub
49 changed files with 5747 additions and 74 deletions
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post, put } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: { get, post, put }
}))
import {
getUpstreamBillingProbeSettings,
probeUpstreamBilling,
probeUpstreamBillingBatch,
setUpstreamBillingProbeEnabled,
updateUpstreamBillingProbeSettings
} from '@/api/admin/accounts'
describe('admin account upstream billing probe API', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
put.mockReset()
})
it('reads and updates global settings', async () => {
const settings = { enabled: true, interval_minutes: 30 }
get.mockResolvedValueOnce({ data: settings })
put.mockResolvedValueOnce({ data: settings })
await expect(getUpstreamBillingProbeSettings()).resolves.toEqual(settings)
await expect(updateUpstreamBillingProbeSettings(settings)).resolves.toEqual(settings)
expect(get).toHaveBeenCalledWith('/admin/accounts/upstream-billing-probe/settings')
expect(put).toHaveBeenCalledWith('/admin/accounts/upstream-billing-probe/settings', settings)
})
it('uses dedicated account and batch endpoints', async () => {
const result = { account_id: 7, snapshot: { status: 'unsupported' } }
put.mockResolvedValueOnce({ data: {} })
post.mockResolvedValueOnce({ data: result })
post.mockResolvedValueOnce({ data: { results: [result] } })
await setUpstreamBillingProbeEnabled(7, true)
await expect(probeUpstreamBilling(7)).resolves.toEqual(result)
await expect(probeUpstreamBillingBatch([7])).resolves.toEqual([result])
expect(put).toHaveBeenCalledWith('/admin/accounts/7/upstream-billing-probe', { enabled: true })
expect(post).toHaveBeenNthCalledWith(1, '/admin/accounts/7/upstream-billing-probe')
expect(post).toHaveBeenNthCalledWith(2, '/admin/accounts/upstream-billing-probe/batch', { account_ids: [7] })
})
})
+41 -2
View File
@@ -20,7 +20,9 @@ import type {
CodexSessionImportResult,
OpenAICodexPATCreateRequest,
CheckMixedChannelRequest,
CheckMixedChannelResponse
CheckMixedChannelResponse,
UpstreamBillingProbeResult,
UpstreamBillingProbeSettings
} from '@/types'
/**
@@ -848,6 +850,38 @@ export async function createSparkShadow(parentId: number, payload: SparkShadowCr
return data
}
export async function getUpstreamBillingProbeSettings(): Promise<UpstreamBillingProbeSettings> {
const { data } = await apiClient.get<UpstreamBillingProbeSettings>('/admin/accounts/upstream-billing-probe/settings')
return data
}
export async function updateUpstreamBillingProbeSettings(
settings: UpstreamBillingProbeSettings
): Promise<UpstreamBillingProbeSettings> {
const { data } = await apiClient.put<UpstreamBillingProbeSettings>(
'/admin/accounts/upstream-billing-probe/settings',
settings
)
return data
}
export async function setUpstreamBillingProbeEnabled(id: number, enabled: boolean): Promise<void> {
await apiClient.put(`/admin/accounts/${id}/upstream-billing-probe`, { enabled })
}
export async function probeUpstreamBilling(id: number): Promise<UpstreamBillingProbeResult> {
const { data } = await apiClient.post<UpstreamBillingProbeResult>(`/admin/accounts/${id}/upstream-billing-probe`)
return data
}
export async function probeUpstreamBillingBatch(accountIds: number[]): Promise<UpstreamBillingProbeResult[]> {
const { data } = await apiClient.post<{ results: UpstreamBillingProbeResult[] }>(
'/admin/accounts/upstream-billing-probe/batch',
{ account_ids: accountIds }
)
return data.results
}
export const accountsAPI = {
list,
listWithEtag,
@@ -894,7 +928,12 @@ export const accountsAPI = {
revertProxyFallback,
queryOpenAIQuota,
resetOpenAIQuota,
createSparkShadow
createSparkShadow,
getUpstreamBillingProbeSettings,
updateUpstreamBillingProbeSettings,
setUpstreamBillingProbeEnabled,
probeUpstreamBilling,
probeUpstreamBillingBatch
}
export default accountsAPI
@@ -1592,6 +1592,23 @@
</div>
</div>
<div
v-if="account?.platform === 'openai' && account?.type === 'apikey'"
class="flex items-center justify-between gap-4 border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div>
<label class="input-label mb-0">{{ t('admin.accounts.upstreamBilling.autoProbe') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.upstreamBilling.autoProbeHint') }}
</p>
</div>
<Toggle
v-model="upstreamBillingAutoProbeEnabled"
data-testid="upstream-billing-auto-probe"
:aria-label="t('admin.accounts.upstreamBilling.autoProbe')"
/>
</div>
<!-- Anthropic API Key 自动透传开关 -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
@@ -2560,6 +2577,7 @@ import type {
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import Toggle from '@/components/common/Toggle.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
@@ -2729,6 +2747,7 @@ const autoPause5hThreshold = ref<number | null>(null)
const autoPause7dThreshold = ref<number | null>(null)
const autoPause5hDisabled = ref(false)
const autoPause7dDisabled = ref(false)
const upstreamBillingAutoProbeEnabled = 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('')
@@ -3210,6 +3229,7 @@ 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
upstreamBillingAutoProbeEnabled.value = extra?.upstream_billing_probe_enabled === true
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
openaiPassthroughEnabled.value = false
@@ -4463,6 +4483,7 @@ const handleSubmit = async () => {
} else {
newExtra.openai_responses_mode = openAIResponsesMode.value
}
newExtra.upstream_billing_probe_enabled = upstreamBillingAutoProbeEnabled.value
}
if (autoPause5hThreshold.value != null && autoPause5hThreshold.value > 0) {
newExtra.auto_pause_5h_threshold = autoPause5hThreshold.value / 100
@@ -0,0 +1,160 @@
<template>
<div v-if="eligible" class="flex h-6 min-w-[7rem] items-center gap-1">
<HelpTooltip class="-ml-1" width-class="w-64" data-testid="upstream-billing-details">
<template #trigger>
<span
class="cursor-help border-b border-dotted border-gray-300 font-mono text-sm font-medium text-gray-800 dark:border-gray-600 dark:text-gray-200"
data-testid="upstream-billing-rate"
>
{{ effectiveRate }}
</span>
</template>
<div v-if="data" class="space-y-1">
<p>{{ t('admin.accounts.upstreamBilling.groupRate', { value: data.group_rate_multiplier }) }}</p>
<p v-if="data.user_rate_multiplier != null">
{{ t('admin.accounts.upstreamBilling.userRate', { value: data.user_rate_multiplier }) }}
</p>
<p>
{{
data.peak_rate_enabled
? t('admin.accounts.upstreamBilling.peakRate', {
start: data.peak_start,
end: data.peak_end,
value: data.peak_rate_multiplier,
timezone: data.timezone
})
: t('admin.accounts.upstreamBilling.noPeakRate')
}}
</p>
<p>{{ t('admin.accounts.upstreamBilling.effectiveRate', { value: currentEffectiveRate ?? '-' }) }}</p>
<p>{{ t('admin.accounts.upstreamBilling.updatedAt', { value: formatDate(snapshot?.received_at) }) }}</p>
</div>
<span v-else>{{ statusLabel }}</span>
</HelpTooltip>
<span v-if="statusLabel" :class="statusClass" class="whitespace-nowrap text-[10px] font-medium">
{{ statusLabel }}
</span>
<button
type="button"
class="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
:disabled="probing"
:aria-label="t('admin.accounts.upstreamBilling.manualProbe')"
:title="t('admin.accounts.upstreamBilling.manualProbe')"
data-testid="upstream-billing-probe"
@click="$emit('probe')"
>
<Icon name="refresh" size="xs" :class="{ 'animate-spin': probing }" />
</button>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import Icon from '@/components/icons/Icon.vue'
import type { Account, UpstreamBillingProbeSnapshot } from '@/types'
const props = defineProps<{
account: Account
intervalMinutes: number
now: number
probing?: boolean
}>()
defineEmits<{
(event: 'probe'): void
}>()
const { t } = useI18n()
const eligible = computed(() => props.account.platform === 'openai' && props.account.type === 'apikey')
const snapshot = computed<UpstreamBillingProbeSnapshot | undefined>(() => props.account.extra?.upstream_billing_probe)
const data = computed(() => snapshot.value?.data)
const receivedAt = computed(() => typeof snapshot.value?.received_at === 'string' ? Date.parse(snapshot.value.received_at) : Number.NaN)
const freshUntil = computed(() => {
if (typeof snapshot.value?.fresh_until === 'string') return Date.parse(snapshot.value.fresh_until)
if (snapshot.value?.status !== 'ok' || typeof snapshot.value.next_probe_at !== 'string') return Number.NaN
const nextProbeAt = Date.parse(snapshot.value.next_probe_at)
return Number.isFinite(nextProbeAt) && nextProbeAt > receivedAt.value
? receivedAt.value + 2 * (nextProbeAt - receivedAt.value)
: Number.NaN
})
const validTimestamps = computed(() => {
if (!Number.isFinite(receivedAt.value) || receivedAt.value > props.now) return false
return Number.isFinite(freshUntil.value) && freshUntil.value > receivedAt.value
})
const stale = computed(() => {
if (!snapshot.value) return false
if (!Number.isFinite(receivedAt.value)) return snapshot.value.status === 'ok'
if (!validTimestamps.value) return true
return props.now > freshUntil.value
})
const parseMinute = (value?: string) => {
if (typeof value !== 'string') return null
const match = /^(\d{2}):(\d{2})$/.exec(value)
if (!match) return null
const hour = Number(match[1])
const minute = Number(match[2])
return hour < 24 && minute < 60 ? hour * 60 + minute : null
}
const minuteInTimeZone = (timestamp: number, timeZone?: string) => {
if (!timeZone) return null
try {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(timestamp))
const hour = Number(parts.find(part => part.type === 'hour')?.value)
const minute = Number(parts.find(part => part.type === 'minute')?.value)
return Number.isInteger(hour) && Number.isInteger(minute) ? hour * 60 + minute : null
} catch {
return null
}
}
const currentEffectiveRate = computed(() => {
const billing = data.value
if (!billing) return null
if (billing.billing_scope !== 'token') return null
const base = billing.resolved_rate_multiplier
if (typeof base !== 'number' || !Number.isFinite(base) || base < 0) return null
if (typeof billing.peak_rate_enabled !== 'boolean') return null
if (!billing.peak_rate_enabled) return base
const start = parseMinute(billing.peak_start)
const end = parseMinute(billing.peak_end)
const minute = minuteInTimeZone(props.now, billing.timezone)
const peak = billing.peak_rate_multiplier
if (start == null || end == null || minute == null || start >= end || typeof peak !== 'number' || !Number.isFinite(peak) || peak < 0) return null
const value = minute >= start && minute < end ? base * peak : base
return Number.isFinite(value) ? value : null
})
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`
})
const statusLabel = computed(() => {
if (!snapshot.value) return t('admin.accounts.upstreamBilling.notProbed')
if (snapshot.value.status === 'unsupported') return t('admin.accounts.upstreamBilling.unsupported')
if (stale.value) return t('admin.accounts.upstreamBilling.stale')
if (snapshot.value.status === 'failed') return t('admin.accounts.upstreamBilling.failed')
return ''
})
const statusClass = computed(() => {
if (!snapshot.value) return 'text-gray-400 dark:text-gray-500'
if (snapshot.value.status === 'unsupported') return 'text-gray-500 dark:text-gray-400'
if (stale.value) return 'text-amber-600 dark:text-amber-400'
if (snapshot.value.status === 'failed') return 'text-red-600 dark:text-red-400'
return ''
})
const formatDate = (value?: string) => value
? new Date(value).toLocaleString(undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
: '-'
</script>
@@ -588,6 +588,24 @@ describe('EditAccountModal', () => {
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(false)
})
it('submits the account upstream billing auto-probe setting', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="upstream-billing-auto-probe"]')
expect(toggle.attributes('aria-checked')).toBe('false')
await toggle.trigger('click')
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)
})
it('clears OpenAI APIKey Responses override when set back to auto', async () => {
const account = buildAccount()
account.extra = {
@@ -0,0 +1,233 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import UpstreamBillingRateCell from '../UpstreamBillingRateCell.vue'
import type { Account } from '@/types'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${Object.values(params).join(',')}` : key
})
}
})
const makeAccount = (overrides: Partial<Account> = {}): Account => ({
id: 1,
name: 'upstream',
platform: 'openai',
type: 'apikey',
proxy_id: null,
concurrency: 1,
priority: 1,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: false,
created_at: '2026-07-13T00:00:00Z',
updated_at: '2026-07-13T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null,
...overrides
})
const billingData = {
object: 'sub2api.key_billing' as const,
schema_version: 1 as const,
billing_scope: 'token' as const,
group_rate_multiplier: 0.8,
resolved_rate_multiplier: 0.6,
peak_rate_enabled: true,
peak_start: '09:00',
peak_end: '18:00',
peak_rate_multiplier: 1.5,
applied_peak_multiplier: 1.5,
effective_rate_multiplier: 0.9,
timezone: 'Asia/Shanghai',
observed_at: '2026-07-13T00:00:00Z'
}
describe('UpstreamBillingRateCell', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T00:30:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('recomputes the current effective rate and keeps the icon-only probe action', async () => {
const wrapper = mount(UpstreamBillingRateCell, {
props: {
account: makeAccount({
extra: {
upstream_billing_probe_enabled: true,
upstream_billing_probe: {
status: 'ok',
data: billingData,
received_at: '2026-07-13T00:00:00Z',
fresh_until: '2026-07-14T00:00:00Z',
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T00:30:00Z'
}
}
}),
intervalMinutes: 30,
now: Date.now()
}
})
expect(wrapper.text()).toContain('0.6x')
await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00Z') })
expect(wrapper.text()).toContain('0.9x')
await wrapper.setProps({ now: Date.parse('2026-07-13T10:00:00Z') })
expect(wrapper.text()).toContain('0.6x')
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(
'admin.accounts.upstreamBilling.manualProbe'
)
})
it('uses retained failed data only while it is still fresh', async () => {
const account = makeAccount({
extra: {
upstream_billing_probe: {
status: 'ok',
data: billingData,
received_at: '2026-07-12T22:00:00Z',
fresh_until: '2026-07-12T23:00:00Z',
last_attempt_at: '2026-07-12T22:00:00Z',
next_probe_at: '2026-07-12T22:30:00Z'
}
}
})
const wrapper = mount(UpstreamBillingRateCell, { props: { account, intervalMinutes: 30, now: Date.now() } })
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.stale')
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
await wrapper.setProps({
account: makeAccount({
extra: {
upstream_billing_probe: {
status: 'failed',
data: billingData,
received_at: '2026-07-13T00:00:00Z',
fresh_until: '2026-07-13T01:00:00Z',
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T01:00:00Z',
last_error: 'http_error'
}
}
})
})
expect(wrapper.text()).toContain('0.6x')
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()).not.toContain('admin.accounts.upstreamBilling.stale')
await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00.001Z') })
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.stale')
await wrapper.setProps({
now: Date.now(),
account: makeAccount({
extra: {
upstream_billing_probe: {
status: 'failed',
data: billingData,
received_at: '2026-07-12T22:00:00Z',
fresh_until: '2026-07-12T23:00:00Z',
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T01:00:00Z',
last_error: 'http_error'
}
}
})
})
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.stale')
})
it('emits manual probe commands only for eligible accounts', async () => {
const wrapper = mount(UpstreamBillingRateCell, {
props: { account: makeAccount(), intervalMinutes: 30, now: Date.now() }
})
await wrapper.get('[data-testid="upstream-billing-probe"]').trigger('click')
expect(wrapper.emitted('probe')).toHaveLength(1)
await wrapper.setProps({ account: makeAccount({ type: 'oauth' }) })
expect(wrapper.findAll('button')).toHaveLength(0)
expect(wrapper.text()).toBe('-')
})
it('fails neutral for malformed data and timestamps', async () => {
const malformedAccount = (
dataOverrides: Partial<typeof billingData> = {},
snapshotOverrides: Record<string, unknown> = {}
) => makeAccount({
extra: {
upstream_billing_probe: {
status: 'ok',
data: { ...billingData, ...dataOverrides },
received_at: '2026-07-13T00:00:00Z',
fresh_until: '2026-07-13T01:00:00Z',
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T01:00:00Z',
...snapshotOverrides
}
}
})
const wrapper = mount(UpstreamBillingRateCell, {
props: {
account: malformedAccount({
resolved_rate_multiplier: -1,
peak_rate_enabled: false,
effective_rate_multiplier: -1
}),
intervalMinutes: 30,
now: Date.now()
}
})
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
await wrapper.setProps({ account: malformedAccount({ billing_scope: 'request' as 'token' }) })
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
await wrapper.setProps({ account: malformedAccount({}, { received_at: 'not-a-time' }) })
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
await wrapper.setProps({ account: malformedAccount({}, { received_at: '2026-07-13T00:31:00Z' }) })
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
await wrapper.setProps({ account: malformedAccount({}, { fresh_until: '2026-07-12T23:59:00Z' }) })
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
await wrapper.setProps({
account: makeAccount({
extra: {
upstream_billing_probe: {
status: 'failed',
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T01:00:00Z',
last_error: 'network_error'
}
}
})
})
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.failed')
expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.stale')
})
})
@@ -28,6 +28,7 @@
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
<button @click="$emit('probe-upstream-billing')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.probeUpstreamBilling') }}</button>
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
<button @click="$emit('edit-selected')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
@@ -41,5 +42,19 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps(['selectedIds']); defineEmits(['delete', 'edit-selected', 'edit-filtered', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n()
defineProps<{ selectedIds: number[] }>()
defineEmits([
'delete',
'edit-selected',
'edit-filtered',
'clear',
'select-page',
'toggle-schedulable',
'reset-status',
'refresh-token',
'probe-upstream-billing'
])
const { t } = useI18n()
</script>
@@ -152,6 +152,7 @@ export default {
notes: 'Notes',
priority: 'Priority',
billingRateMultiplier: 'Billing Rate',
upstreamBillingRate: 'Upstream Declared Rate',
weight: 'Weight',
schedulerScore: 'Scheduler Score',
status: 'Status',
@@ -172,6 +173,31 @@ export default {
hint: 'Displayed as "group / base score / sticky bonus". The base score is computed within the current filtered candidate set and includes priority, load, queue depth, error rate, first-token latency, reset window, quota headroom, and related factors. The sticky bonus applies only when sticky weighting is enabled for previous_response_id or session_hash. Higher scores are preferred.'
},
usageWindowsHint: '"5h / 7d" are the upstream account\'s official rolling usage windows (e.g. OpenAI ChatGPT, Claude). They are imposed by the upstream provider on the account itself — not configured by sub2api, and unrelated to the models you map. Usage resets automatically once each window rolls over, and the limit cannot be lifted from within sub2api.',
upstreamBilling: {
trustWarning: 'This rate is declared by the upstream site for the current API key. Sub2API cannot verify that it matches actual charges. The upstream site or an intermediary may return forged, stale, or modified data. Verify it against bills, balance changes, and actual usage.',
autoProbeSettings: 'Upstream rate auto probe',
intervalMinutes: 'Probe interval (minutes)',
autoProbe: 'Auto probe',
autoProbeHint: 'Probe this account on the global interval when global probing is enabled.',
manualProbe: 'Probe upstream rate now',
stale: 'Stale',
unsupported: 'Unsupported',
failed: 'Failed',
notProbed: 'Not probed',
groupRate: 'Group default: {value}x',
userRate: 'User rate: {value}x',
peakRate: 'Peak: {start}-{end}, {value}x ({timezone})',
noPeakRate: 'Peak rate: disabled',
effectiveRate: 'Current rate: {value}x',
updatedAt: 'Updated: {value}',
settingsSaved: 'Upstream rate probe settings saved',
settingsFailed: 'Failed to save upstream rate probe settings',
probeFailed: 'Failed to probe upstream rate',
noEligibleAccounts: 'Select OpenAI API key accounts',
batchLimit: 'A batch can probe at most 20 accounts',
batchCompleted: 'Probed {count} account(s)',
batchPartial: 'Probe partially completed: {success} succeeded, {failed} failed'
},
allPrivacyModes: 'All Privacy States',
privacyUnset: 'Unset',
privacyTrainingOff: 'Training data sharing disabled',
@@ -313,6 +339,7 @@ export default {
disableScheduling: 'Disable Scheduling',
resetStatus: 'Reset Status',
refreshToken: 'Refresh Token',
probeUpstreamBilling: 'Probe Upstream Rate',
resetStatusSuccess: 'Successfully reset {count} account(s) status',
refreshTokenSuccess: 'Successfully refreshed {count} account(s) token',
partialSuccess: 'Partially completed: {success} succeeded, {failed} failed'
@@ -108,6 +108,7 @@ export default {
notes: '备注',
priority: '优先级',
billingRateMultiplier: '账号倍率',
upstreamBillingRate: '上游声明倍率',
weight: '权重',
schedulerScore: '调度权值',
status: '状态',
@@ -128,6 +129,31 @@ export default {
hint: '显示格式为“分组名 / 基础分 / 粘性加分”。基础分按当前筛选条件限定的候选账号计算,包含优先级、负载、排队、错误率、首包延迟、重置窗口、额度余量等因子;粘性加分只在开启粘性加权时用于 previous_response_id 或 session_hash。分数越大越优先。'
},
usageWindowsHint: '“5h / 7d”是上游账号(如 OpenAI ChatGPT、Claude)官方的滚动用量窗口限制,由上游对账号设定,并非 sub2api 配置,也与你映射的模型无关。窗口滚动到期后用量会自动重置,无法在 sub2api 端解除该限制。',
upstreamBilling: {
trustWarning: '此倍率由上游站点针对当前 API Key 自行声明。Sub2API 无法验证该值是否与实际扣费一致;上游站点或中间代理可能返回伪造、过期或被篡改的数据。请结合账单、余额变化和实际用量自行核验。',
autoProbeSettings: '上游倍率自动探测',
intervalMinutes: '探测周期(分钟)',
autoProbe: '自动探测',
autoProbeHint: '启用后按全局探测周期查询此账号;全局探测关闭时不会执行。',
manualProbe: '立即探测上游倍率',
stale: '已过期',
unsupported: '不支持',
failed: '失败',
notProbed: '未探测',
groupRate: '分组默认:{value}x',
userRate: '用户专属倍率:{value}x',
peakRate: '高峰:{start}-{end},{value}x({timezone})',
noPeakRate: '高峰倍率:未启用',
effectiveRate: '当前倍率:{value}x',
updatedAt: '更新时间:{value}',
settingsSaved: '上游倍率探测设置已保存',
settingsFailed: '保存上游倍率探测设置失败',
probeFailed: '探测上游倍率失败',
noEligibleAccounts: '请选择 OpenAI API Key 账号',
batchLimit: '每次最多探测 20 个账号',
batchCompleted: '已完成 {count} 个账号的倍率探测',
batchPartial: '倍率探测部分完成:成功 {success} 个,失败 {failed} 个'
},
allPrivacyModes: '全部Privacy状态',
privacyUnset: '未设置',
privacyTrainingOff: '已关闭训练数据共享',
@@ -417,6 +443,7 @@ export default {
disableScheduling: '批量停止调度',
resetStatus: '批量重置状态',
refreshToken: '批量刷新令牌',
probeUpstreamBilling: '探测上游倍率',
resetStatusSuccess: '已成功重置 {count} 个账号状态',
refreshTokenSuccess: '已成功刷新 {count} 个账号令牌',
partialSuccess: '操作部分完成:{success} 成功,{failed} 失败'
+44
View File
@@ -865,6 +865,48 @@ export interface TempUnschedulableStatus {
state?: TempUnschedulableState
}
export interface UpstreamBillingData {
object: 'sub2api.key_billing'
schema_version: 1
billing_scope: 'token'
group_rate_multiplier: number
user_rate_multiplier?: number
resolved_rate_multiplier: number
peak_rate_enabled: boolean
peak_start?: string
peak_end?: string
peak_rate_multiplier?: number
applied_peak_multiplier?: number
effective_rate_multiplier: number
timezone?: string
observed_at: string
}
export type UpstreamBillingProbeStatus = 'ok' | 'unsupported' | 'failed'
export interface UpstreamBillingProbeSnapshot {
status: UpstreamBillingProbeStatus
data?: UpstreamBillingData
received_at?: string
fresh_until?: string
last_attempt_at: string
next_probe_at: string
failure_count?: number
http_status?: number
last_error?: string
}
export interface UpstreamBillingProbeSettings {
enabled: boolean
interval_minutes: number
}
export interface UpstreamBillingProbeResult {
account_id: number
snapshot?: UpstreamBillingProbeSnapshot
error?: string
}
export interface Account {
id: number
name: string
@@ -881,6 +923,8 @@ export interface Account {
extra?: (CodexUsageSnapshot & OpenAICompactState & {
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
upstream_billing_probe_enabled?: boolean
upstream_billing_probe?: UpstreamBillingProbeSnapshot
} & Record<string, unknown>)
proxy_id: number | null
proxy_fallback_origin_id?: number | null
+141 -1
View File
@@ -132,6 +132,41 @@
<span class="flex-1 text-left">{{ t('admin.tlsFingerprintProfiles.title') }}</span>
</button>
<div class="my-2 border-t border-gray-100 dark:border-gray-700"></div>
<div class="space-y-2 px-3 py-2">
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('admin.accounts.upstreamBilling.autoProbeSettings') }}
</span>
<Toggle
v-model="upstreamBillingProbeSettings.enabled"
:aria-label="t('admin.accounts.upstreamBilling.autoProbeSettings')"
/>
</div>
<div class="flex items-center gap-2">
<label class="flex-1 text-xs text-gray-500 dark:text-gray-400" for="upstream-billing-probe-interval">
{{ t('admin.accounts.upstreamBilling.intervalMinutes') }}
</label>
<input
id="upstream-billing-probe-interval"
v-model.number="upstreamBillingProbeSettings.interval_minutes"
type="number"
min="5"
max="1440"
class="input h-8 w-20 px-2 text-sm"
/>
<button
type="button"
class="btn btn-secondary h-8 px-2"
:disabled="upstreamBillingSettingsLoading || upstreamBillingSettingsSaving"
:title="t('common.save')"
@click="saveUpstreamBillingProbeSettings"
>
<Icon name="check" size="sm" />
</button>
</div>
</div>
<div class="my-2 border-t border-gray-100 dark:border-gray-700"></div>
<div class="px-2 py-2">
<div class="flex items-center justify-between gap-3">
@@ -177,6 +212,7 @@
@delete="handleBulkDelete"
@reset-status="handleBulkResetStatus"
@refresh-token="handleBulkRefreshToken"
@probe-upstream-billing="handleBulkProbeUpstreamBilling"
@edit-selected="openBulkEditSelected"
@edit-filtered="openBulkEditFiltered"
@clear="clearSelection"
@@ -321,6 +357,21 @@
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
</span>
</template>
<template #header-upstream_billing_rate="{ column }">
<div class="flex items-center">
<span>{{ column.label }}</span>
<HelpTooltip :content="t('admin.accounts.upstreamBilling.trustWarning')" width-class="w-80" />
</div>
</template>
<template #cell-upstream_billing_rate="{ row }">
<UpstreamBillingRateCell
:account="row"
:interval-minutes="upstreamBillingProbeSettings.interval_minutes"
:now="upstreamBillingNow"
:probing="probingUpstreamBilling.has(row.id)"
@probe="handleProbeUpstreamBilling(row)"
/>
</template>
<template #cell-priority="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
</template>
@@ -441,6 +492,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import Toggle from '@/components/common/Toggle.vue'
import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import { CreateAccountModal, EditAccountModal, BulkEditAccountModal, SyncFromCrsModal, TempUnschedStatusModal } from '@/components/account'
@@ -459,6 +511,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
import UpstreamBillingRateCell from '@/components/account/UpstreamBillingRateCell.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import Icon from '@/components/icons/Icon.vue'
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
@@ -466,7 +519,8 @@ import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfil
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
import { extractApiErrorMessage } from '@/utils/apiError'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSettings, UpstreamBillingProbeSnapshot } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
@@ -544,6 +598,15 @@ const scheduleModelOptions = ref<SelectOption[]>([])
const togglingSchedulable = ref<number | null>(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const exportingData = ref(false)
const upstreamBillingProbeSettings = reactive<UpstreamBillingProbeSettings>({
enabled: true,
interval_minutes: 30
})
const upstreamBillingSettingsLoading = ref(false)
const upstreamBillingSettingsSaving = ref(false)
const probingUpstreamBilling = reactive(new Set<number>())
const upstreamBillingNow = ref(Date.now())
useIntervalFn(() => { upstreamBillingNow.value = Date.now() }, 60_000)
// Account tools dropdown
const showAccountToolsDropdown = ref(false)
@@ -1113,6 +1176,31 @@ const openTLSFingerprintProfiles = () => {
showTLSFingerprintProfiles.value = true
}
const loadUpstreamBillingProbeSettings = async () => {
upstreamBillingSettingsLoading.value = true
try {
Object.assign(upstreamBillingProbeSettings, await adminAPI.accounts.getUpstreamBillingProbeSettings())
} catch (error) {
console.error('Failed to load upstream billing probe settings:', error)
} finally {
upstreamBillingSettingsLoading.value = false
}
}
const saveUpstreamBillingProbeSettings = async () => {
upstreamBillingSettingsSaving.value = true
try {
const saved = await adminAPI.accounts.updateUpstreamBillingProbeSettings({ ...upstreamBillingProbeSettings })
Object.assign(upstreamBillingProbeSettings, saved)
appStore.showSuccess(t('admin.accounts.upstreamBilling.settingsSaved'))
} catch (error) {
console.error('Failed to save upstream billing probe settings:', error)
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.upstreamBilling.settingsFailed')))
} finally {
upstreamBillingSettingsSaving.value = false
}
}
const syncPendingListChanges = async () => {
hasPendingListSync.value = false
await load()
@@ -1282,6 +1370,7 @@ const allColumns = computed(() => {
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'scheduler_score', label: t('admin.accounts.columns.schedulerScore'), sortable: false },
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
{ key: 'upstream_billing_rate', label: t('admin.accounts.columns.upstreamBillingRate'), sortable: false },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'created_at', label: t('admin.accounts.columns.createdAt'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
@@ -1392,6 +1481,35 @@ const handleBulkRefreshToken = async () => {
appStore.showError(String(error))
}
}
const handleBulkProbeUpstreamBilling = async () => {
const accountIDs = [...selIds.value]
if (accountIDs.length === 0) {
appStore.showError(t('admin.accounts.upstreamBilling.noEligibleAccounts'))
return
}
if (accountIDs.length > 20) {
appStore.showError(t('admin.accounts.upstreamBilling.batchLimit'))
return
}
accountIDs.forEach(id => probingUpstreamBilling.add(id))
try {
const results = await adminAPI.accounts.probeUpstreamBillingBatch(accountIDs)
results.forEach(result => {
if (result.snapshot) patchUpstreamBillingSnapshot(result.account_id, result.snapshot)
})
const failed = results.filter(result => result.error).length
if (failed > 0) {
appStore.showError(t('admin.accounts.upstreamBilling.batchPartial', { success: results.length - failed, failed }))
} else {
appStore.showSuccess(t('admin.accounts.upstreamBilling.batchCompleted', { count: results.length }))
}
} catch (error) {
console.error('Failed to probe upstream billing in batch:', error)
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.upstreamBilling.probeFailed')))
} finally {
accountIDs.forEach(id => probingUpstreamBilling.delete(id))
}
}
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
if (accountIds.length === 0) return
const idSet = new Set(accountIds)
@@ -1641,6 +1759,27 @@ const patchAccountInList = (updatedAccount: Account) => {
accounts.value = nextAccounts
syncAccountRefs(mergedAccount)
}
const patchUpstreamBillingSnapshot = (accountID: number, snapshot: UpstreamBillingProbeSnapshot) => {
const account = accounts.value.find(item => item.id === accountID)
if (!account) return
patchAccountInList({
...account,
extra: { ...account.extra, upstream_billing_probe: snapshot }
})
}
const handleProbeUpstreamBilling = async (account: Account) => {
if (probingUpstreamBilling.has(account.id)) return
probingUpstreamBilling.add(account.id)
try {
const result = await adminAPI.accounts.probeUpstreamBilling(account.id)
if (result.snapshot) patchUpstreamBillingSnapshot(account.id, result.snapshot)
} catch (error) {
console.error('Failed to probe upstream billing:', error)
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.upstreamBilling.probeFailed')))
} finally {
probingUpstreamBilling.delete(account.id)
}
}
const handleAccountUpdated = (updatedAccount: Account) => {
patchAccountInList(updatedAccount)
enterAutoRefreshSilentWindow()
@@ -1885,6 +2024,7 @@ const handleClickOutside = (event: MouseEvent) => {
onMounted(async () => {
load()
loadUpstreamBillingProbeSettings()
try {
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
proxies.value = p
@@ -8,13 +8,15 @@ const {
listWithEtag,
getBatchTodayStats,
getAllProxies,
getAllGroups
getAllGroups,
probeUpstreamBillingBatch
} = vi.hoisted(() => ({
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
getBatchTodayStats: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn()
getAllGroups: vi.fn(),
probeUpstreamBillingBatch: vi.fn()
}))
vi.mock('@/api/admin', () => ({
@@ -23,9 +25,11 @@ vi.mock('@/api/admin', () => ({
list: listAccounts,
listWithEtag,
getBatchTodayStats,
getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: true, interval_minutes: 30 }),
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
probeUpstreamBillingBatch,
toggleSchedulable: vi.fn()
},
proxies: {
@@ -67,6 +71,7 @@ const DataTableStub = {
<div data-test="data-table">
<span v-for="column in columns" :key="column.key" data-test="column-key">{{ column.key }}</span>
<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>
</div>
@@ -75,8 +80,18 @@ const DataTableStub = {
const AccountBulkActionsBarStub = {
props: ['selectedIds'],
emits: ['edit-filtered'],
template: '<button data-test="edit-filtered" @click="$emit(\'edit-filtered\')">edit filtered</button>'
emits: ['edit-filtered', 'probe-upstream-billing'],
template: `
<div>
<button data-test="edit-filtered" @click="$emit('edit-filtered')">edit filtered</button>
<button data-test="probe-upstream-billing" @click="$emit('probe-upstream-billing')">probe</button>
</div>
`
}
const PaginationStub = {
emits: ['update:page'],
template: '<button data-test="next-page" @click="$emit(\'update:page\', 2)">next</button>'
}
const BulkEditAccountModalStub = {
@@ -93,6 +108,7 @@ describe('admin AccountsView bulk edit scope', () => {
getBatchTodayStats.mockReset()
getAllProxies.mockReset()
getAllGroups.mockReset()
probeUpstreamBillingBatch.mockReset()
listAccounts.mockResolvedValue({
items: [],
@@ -109,6 +125,7 @@ describe('admin AccountsView bulk edit scope', () => {
getBatchTodayStats.mockResolvedValue({ stats: {} })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
probeUpstreamBillingBatch.mockResolvedValue([])
})
it('opens bulk edit in filtered-results mode from the bulk actions dropdown', async () => {
@@ -224,4 +241,65 @@ describe('admin AccountsView bulk edit scope', () => {
sortable: true
})
})
it('submits selected account IDs from every page for backend eligibility checks', async () => {
const account = (id: number) => ({
id,
name: `account-${id}`,
platform: 'openai',
type: 'apikey',
status: 'active',
schedulable: true,
created_at: '2026-07-13T00:00:00Z',
updated_at: '2026-07-13T00:00:00Z'
})
listAccounts
.mockResolvedValueOnce({ items: [account(7)], total: 2, page: 1, page_size: 1, pages: 2 })
.mockResolvedValueOnce({ items: [account(11)], total: 2, page: 2, page_size: 1, pages: 2 })
const wrapper = mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: { template: '<div><slot name="table" /><slot name="pagination" /></div>' },
DataTable: DataTableStub,
Pagination: PaginationStub,
ConfirmDialog: true,
AccountTableActions: true,
AccountTableFilters: true,
AccountBulkActionsBar: AccountBulkActionsBarStub,
AccountActionMenu: 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="select-row"] input').trigger('change')
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([7, 11])
})
})
@@ -23,6 +23,7 @@ vi.mock('@/api/admin', () => ({
list: listAccounts,
listWithEtag,
getBatchTodayStats,
getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: true, interval_minutes: 30 }),
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
@@ -37,6 +37,7 @@ vi.mock('@/api/admin', () => ({
listWithEtag,
getBatchTodayStats,
duplicate: duplicateAccount,
getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: true, interval_minutes: 30 }),
createSparkShadow,
delete: vi.fn(),
batchClearError: vi.fn(),
@@ -23,6 +23,7 @@ vi.mock('@/api/admin', () => ({
list: listAccounts,
listWithEtag,
getBatchTodayStats,
getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: true, interval_minutes: 30 }),
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
@@ -70,6 +71,9 @@ const DataTableStub = {
<div v-if="column.key === 'usage'" data-test="usage-header">
<slot :name="'header-' + column.key" :column="column" />
</div>
<div v-if="column.key === 'upstream_billing_rate'" data-test="upstream-billing-header">
<slot :name="'header-' + column.key" :column="column" />
</div>
</template>
</div>
`
@@ -161,4 +165,16 @@ describe('admin AccountsView usage windows hint', () => {
expect(hint.exists()).toBe(true)
expect(hint.text()).toBe('admin.accounts.usageWindowsHint')
})
it('renders the upstream billing trust warning next to the declared-rate column', async () => {
const wrapper = mountView()
await flushPromises()
const header = wrapper.find('[data-test="upstream-billing-header"]')
expect(header.exists()).toBe(true)
expect(header.text()).toContain('admin.accounts.columns.upstreamBillingRate')
expect(wrapper.findAll('[data-test="usage-windows-hint"]').some(node =>
node.text() === 'admin.accounts.upstreamBilling.trustWarning'
)).toBe(true)
})
})