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
@@ -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>