Merge pull request #4478 from yardbirds0/codex/fix-upstream-billing-probe-refresh

fix: 完善上游 Sub2API 倍率探测刷新、展示与账号配置
This commit is contained in:
Wesley Liddick
2026-07-18 20:49:14 +08:00
committed by GitHub
30 changed files with 1311 additions and 183 deletions
@@ -854,6 +854,45 @@
</div>
</div>
<!-- Upstream billing auto probe (OpenAI API Key only) -->
<div v-if="allOpenAIAPIKey" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div class="flex-1 pr-4">
<label
id="bulk-edit-upstream-billing-auto-probe-label"
class="input-label mb-0"
for="bulk-edit-upstream-billing-auto-probe-enabled"
>
{{ 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>
<input
v-model="enableUpstreamBillingAutoProbe"
id="bulk-edit-upstream-billing-auto-probe-enabled"
type="checkbox"
aria-controls="bulk-edit-upstream-billing-auto-probe"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div
id="bulk-edit-upstream-billing-auto-probe"
:class="!enableUpstreamBillingAutoProbe && 'pointer-events-none opacity-50'"
role="group"
aria-labelledby="bulk-edit-upstream-billing-auto-probe-label"
>
<Select
v-model="upstreamBillingAutoProbeMode"
:disabled="!enableUpstreamBillingAutoProbe"
data-testid="bulk-edit-upstream-billing-auto-probe-select"
:options="upstreamBillingAutoProbeOptions"
aria-labelledby="bulk-edit-upstream-billing-auto-probe-label"
/>
</div>
</div>
<!-- OpenAI API Key WS mode -->
<div v-if="allOpenAIAPIKey" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -1361,6 +1400,7 @@ const enableGroups = ref(false)
const enableOpenAIPassthrough = ref(false)
const enableOpenAIWSMode = ref(false)
const enableOpenAIAPIKeyWSMode = ref(false)
const enableUpstreamBillingAutoProbe = ref(false)
const enableCodexCLIOnly = ref(false)
const enableCodexCLIOnlyAppServer = ref(false)
const enableOpenAICompactMode = ref(false)
@@ -1391,6 +1431,7 @@ const groupIds = ref<number[]>([])
const openaiPassthroughEnabled = ref(false)
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled')
const codexCLIOnlyEnabled = ref(false)
const codexCLIOnlyAppServerEnabled = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
@@ -1421,6 +1462,10 @@ const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
const upstreamBillingAutoProbeOptions = computed(() => [
{ value: 'enabled', label: t('common.enabled') },
{ value: 'disabled', label: t('common.disabled') }
])
const isOpenAIModelRestrictionDisabled = computed(
() =>
allOpenAIPassthroughCapable.value &&
@@ -1648,6 +1693,10 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
)
}
if (enableUpstreamBillingAutoProbe.value) {
updates.upstream_billing_probe_enabled = upstreamBillingAutoProbeMode.value === 'enabled'
}
if (enableCodexCLIOnly.value) {
const extra = ensureExtra()
extra.codex_cli_only = codexCLIOnlyEnabled.value
@@ -1770,6 +1819,7 @@ const handleSubmit = async () => {
enableGroups.value ||
enableOpenAIWSMode.value ||
enableOpenAIAPIKeyWSMode.value ||
enableUpstreamBillingAutoProbe.value ||
enableCodexCLIOnly.value ||
enableCodexCLIOnlyAppServer.value ||
enableOpenAICompactMode.value ||
@@ -1898,6 +1948,7 @@ watch(
enableOpenAIPassthrough.value = false
enableOpenAIWSMode.value = false
enableOpenAIAPIKeyWSMode.value = false
enableUpstreamBillingAutoProbe.value = false
enableCodexCLIOnly.value = false
enableCodexCLIOnlyAppServer.value = false
enableOpenAICompactMode.value = false
@@ -1924,6 +1975,7 @@ watch(
groupIds.value = []
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
upstreamBillingAutoProbeMode.value = 'enabled'
codexCLIOnlyEnabled.value = false
codexCLIOnlyAppServerEnabled.value = false
openAICompactMode.value = 'auto'
@@ -1143,6 +1143,23 @@
<p v-if="apiKeyHint" class="input-hint">{{ apiKeyHint }}</p>
</div>
<div
v-if="form.platform === 'openai'"
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>
<!-- Gemini API Key tier selection -->
<div v-if="form.platform === 'gemini'">
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
@@ -3525,6 +3542,7 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import Toggle from '@/components/common/Toggle.vue'
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
import {
@@ -3668,6 +3686,7 @@ const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_acco
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
const upstreamBillingAutoProbeEnabled = ref(true)
const syncPreviewCredentials = computed(() => {
if (!apiKeyValue.value) return undefined
@@ -4553,7 +4572,18 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<v
const submitCreateAccount = async (payload: CreateAccountRequest) => {
submitting.value = true
try {
await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
if (
payload.platform === 'openai' &&
payload.type === 'apikey' &&
payload.upstream_billing_probe_enabled === true
) {
try {
await adminAPI.accounts.probeUpstreamBilling(account.id)
} catch {
appStore.showWarning(t('admin.accounts.upstreamBilling.probeFailed'))
}
}
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
@@ -4593,6 +4623,7 @@ const resetForm = () => {
addMethod.value = 'oauth'
apiKeyBaseUrl.value = 'https://api.anthropic.com'
apiKeyValue.value = ''
upstreamBillingAutoProbeEnabled.value = true
editQuotaLimit.value = null
editQuotaDailyLimit.value = null
editQuotaWeeklyLimit.value = null
@@ -5092,6 +5123,8 @@ const handleSubmit = async () => {
...form,
group_ids: form.group_ids,
extra,
upstream_billing_probe_enabled:
form.platform === 'openai' ? upstreamBillingAutoProbeEnabled.value : undefined,
auto_pause_on_expired: autoPauseOnExpired.value
})
}
@@ -1,37 +1,71 @@
<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">
<HelpTooltip class="-ml-1" width-class="w-max max-w-[calc(100vw-2rem)]" 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"
class="cursor-help border-b border-dotted border-gray-300 text-sm font-medium dark:border-gray-600"
:class="hasEffectiveRate ? 'font-mono text-gray-800 dark:text-gray-200' : statusClass || 'text-gray-400 dark:text-gray-500'"
data-testid="upstream-billing-rate"
>
{{ effectiveRate }}
{{ primaryValue }}
</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 }) }}
<div class="space-y-1">
<template v-if="hasEffectiveRate && data">
<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>
</template>
<template v-else-if="stale && lastDetectedRate != null">
<p data-testid="upstream-billing-last-rate">
{{ t('admin.accounts.upstreamBilling.lastDetectedRate', { value: lastDetectedRate }) }}
</p>
<p data-testid="upstream-billing-last-time">
{{ t('admin.accounts.upstreamBilling.lastDetectedAt', { value: formatDate(snapshot?.received_at) }) }}
</p>
<p data-testid="upstream-billing-elapsed">
{{ t('admin.accounts.upstreamBilling.elapsedSince', { value: elapsedSinceLastSuccess }) }}
</p>
</template>
<p v-else>{{ statusLabel || '-' }}</p>
<p
v-if="probeEnabled && globalProbeEnabled !== false && nextProbeAt"
data-testid="upstream-billing-next-probe"
>
{{ t('admin.accounts.upstreamBilling.nextProbeAt', { value: formatDate(nextProbeAt) }) }}
</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 class="mt-2 border-t border-white/15 pt-2" data-testid="upstream-billing-probe-state">
{{ t('admin.accounts.upstreamBilling.accountProbeState') }}
<span :class="probeEnabled ? 'text-emerald-400' : 'text-red-400'">
{{ probeEnabled ? t('admin.accounts.upstreamBilling.enabled') : t('admin.accounts.upstreamBilling.disabled') }}
</span>
</p>
<p
v-if="globalProbeEnabled === false"
class="mt-1"
data-testid="upstream-billing-global-probe-state"
>
{{ t('admin.accounts.upstreamBilling.globalProbeState') }}
<span class="text-red-400">{{ t('admin.accounts.upstreamBilling.disabled') }}</span>
</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">
<span v-if="hasEffectiveRate && statusLabel" :class="statusClass" class="whitespace-nowrap text-[10px] font-medium">
{{ statusLabel }}
</span>
<button
@@ -56,21 +90,29 @@ import HelpTooltip from '@/components/common/HelpTooltip.vue'
import Icon from '@/components/icons/Icon.vue'
import type { Account, UpstreamBillingProbeSnapshot } from '@/types'
const props = defineProps<{
const props = withDefaults(defineProps<{
account: Account
intervalMinutes: number
now: number
probing?: boolean
}>()
globalProbeEnabled?: boolean
}>(), {
globalProbeEnabled: true
})
defineEmits<{
(event: 'probe'): void
}>()
const { t } = useI18n()
const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000
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 probeEnabled = computed(() => props.account.extra?.upstream_billing_probe_enabled === true)
const nextProbeAt = computed(() => {
const value = snapshot.value?.next_probe_at
return typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : ''
})
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)
@@ -81,7 +123,7 @@ const freshUntil = computed(() => {
: Number.NaN
})
const validTimestamps = computed(() => {
if (!Number.isFinite(receivedAt.value) || receivedAt.value > props.now) return false
if (!Number.isFinite(receivedAt.value) || receivedAt.value > props.now + CLOCK_SKEW_TOLERANCE_MS) return false
return Number.isFinite(freshUntil.value) && freshUntil.value > receivedAt.value
})
const stale = computed(() => {
@@ -130,6 +172,21 @@ const currentEffectiveRate = computed(() => {
const value = minute >= start && minute < end ? base * peak : base
return Number.isFinite(value) ? value : null
})
const lastDetectedRate = computed(() => {
const value = data.value?.effective_rate_multiplier
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? Number(value.toPrecision(12))
: null
})
const elapsedSinceLastSuccess = computed(() => {
if (!Number.isFinite(receivedAt.value)) return '-'
const elapsedMinutes = Math.max(0, Math.floor((props.now - receivedAt.value) / 60_000))
if (elapsedMinutes < 1) return t('admin.accounts.upstreamBilling.justNow')
if (elapsedMinutes < 60) return t('admin.accounts.upstreamBilling.minutesAgo', { count: elapsedMinutes })
const elapsedHours = Math.floor(elapsedMinutes / 60)
if (elapsedHours < 24) return t('admin.accounts.upstreamBilling.hoursAgo', { count: elapsedHours })
return t('admin.accounts.upstreamBilling.daysAgo', { count: Math.floor(elapsedHours / 24) })
})
const effectiveRate = computed(() => {
if (!validTimestamps.value || stale.value || !['ok', 'failed'].includes(snapshot.value?.status ?? '')) return '-'
const value = currentEffectiveRate.value
@@ -149,6 +206,8 @@ const statusClass = computed(() => {
if (snapshot.value.status === 'failed') return 'text-red-600 dark:text-red-400'
return ''
})
const hasEffectiveRate = computed(() => effectiveRate.value !== '-')
const primaryValue = computed(() => hasEffectiveRate.value ? effectiveRate.value : statusLabel.value || '-')
const formatDate = (value?: string) => value
? new Date(value).toLocaleString(undefined, {
month: '2-digit',
@@ -343,6 +343,73 @@ describe('BulkEditAccountModal', () => {
})
})
it('OpenAI API Key 批量编辑可统一开启上游倍率自动探测', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
selectedTypes: ['apikey']
})
await wrapper.get('#bulk-edit-upstream-billing-auto-probe-enabled').setValue(true)
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
await flushPromises()
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
upstream_billing_probe_enabled: true
})
})
it('OpenAI API Key 批量编辑可统一关闭上游倍率自动探测', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
selectedTypes: ['apikey']
})
await wrapper.get('#bulk-edit-upstream-billing-auto-probe-enabled').setValue(true)
await wrapper.get('[data-testid="bulk-edit-upstream-billing-auto-probe-select"]').setValue('disabled')
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
await flushPromises()
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
upstream_billing_probe_enabled: false
})
})
it('非 OpenAI API Key 目标不显示上游倍率自动探测批量开关', () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
selectedTypes: ['oauth']
})
expect(wrapper.find('#bulk-edit-upstream-billing-auto-probe-enabled').exists()).toBe(false)
})
it('筛选结果批量编辑可统一开启上游倍率自动探测', async () => {
const wrapper = mountModal({
accountIds: [],
selectedPlatforms: [],
selectedTypes: [],
target: {
mode: 'filtered',
filters: { platform: 'openai', type: 'apikey', status: 'active' },
previewCount: 20,
selectedPlatforms: ['openai'],
selectedTypes: ['apikey']
}
})
await wrapper.get('#bulk-edit-upstream-billing-auto-probe-enabled').setValue(true)
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
await flushPromises()
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith({
filters: { platform: 'openai', type: 'apikey', status: 'active' },
upstream_billing_probe_enabled: true
})
})
it('筛选 OpenAI 账号批量编辑应提交 Compact 模式和专属模型映射', async () => {
const wrapper = mountModal({
accountIds: [],
@@ -4,10 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createAccountMock,
probeUpstreamBillingMock,
importCodexSessionMock,
createOpenAICodexPATMock,
} = vi.hoisted(() => ({
createAccountMock: vi.fn(),
probeUpstreamBillingMock: vi.fn(),
importCodexSessionMock: vi.fn(),
createOpenAICodexPATMock: vi.fn(),
}))
@@ -28,6 +30,7 @@ vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
create: createAccountMock,
probeUpstreamBilling: probeUpstreamBillingMock,
checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }),
importCodexSession: importCodexSessionMock,
createOpenAICodexPAT: createOpenAICodexPATMock,
@@ -108,7 +111,11 @@ async function selectButtonByText(wrapper: ReturnType<typeof mountModal>, text:
await button?.trigger('click')
}
async function submitApiKeyAccount(platform: 'openai' | 'anthropic', enableLongContextBilling = false) {
async function submitApiKeyAccount(
platform: 'openai' | 'anthropic',
enableLongContextBilling = false,
disableUpstreamBillingProbe = false
) {
const wrapper = mountModal()
await selectButtonByText(wrapper, platform === 'openai' ? 'OpenAI' : 'admin.accounts.claudeConsole')
if (platform === 'openai') {
@@ -119,8 +126,12 @@ async function submitApiKeyAccount(platform: 'openai' | 'anthropic', enableLongC
if (enableLongContextBilling) {
await wrapper.get('[data-testid="openai-long-context-billing-toggle"]').trigger('click')
}
if (disableUpstreamBillingProbe) {
await wrapper.get('[data-testid="upstream-billing-auto-probe"]').trigger('click')
}
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
return wrapper
}
async function openCodexImportStep(toggleClicks = 0) {
@@ -136,7 +147,8 @@ async function openCodexImportStep(toggleClicks = 0) {
describe('CreateAccountModal OpenAI long-context billing', () => {
beforeEach(() => {
createAccountMock.mockReset().mockResolvedValue({})
createAccountMock.mockReset().mockResolvedValue({ id: 42, platform: 'openai', type: 'apikey' })
probeUpstreamBillingMock.mockReset().mockResolvedValue({})
importCodexSessionMock.mockReset().mockResolvedValue({
created: 1,
updated: 0,
@@ -155,6 +167,38 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('enables upstream billing probes by default for new OpenAI API key accounts', async () => {
await submitApiKeyAccount('openai')
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(true)
})
it('waits for the initial upstream billing probe before refreshing the account list', async () => {
let resolveProbe: (() => void) | undefined
probeUpstreamBillingMock.mockImplementationOnce(
() => new Promise<void>((resolve) => {
resolveProbe = resolve
})
)
const wrapper = await submitApiKeyAccount('openai')
expect(probeUpstreamBillingMock).toHaveBeenCalledWith(42)
expect(wrapper.emitted('created')).toBeUndefined()
resolveProbe?.()
await flushPromises()
expect(wrapper.emitted('created')).toHaveLength(1)
})
it('sends an explicit disabled state when the create toggle is turned off', async () => {
await submitApiKeyAccount('openai', false, true)
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(false)
expect(probeUpstreamBillingMock).not.toHaveBeenCalled()
})
it('exposes Agent Identity in the OpenAI authorization methods', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
@@ -195,6 +239,7 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBeUndefined()
})
it('leaves Codex session import billing ownership to the backend', async () => {
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import UpstreamBillingRateCell from '../UpstreamBillingRateCell.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import type { Account } from '@/types'
vi.mock('vue-i18n', async () => {
@@ -83,7 +84,6 @@ describe('UpstreamBillingRateCell', () => {
}
}
}),
intervalMinutes: 30,
now: Date.now()
}
})
@@ -113,9 +113,9 @@ describe('UpstreamBillingRateCell', () => {
}
}
})
const wrapper = mount(UpstreamBillingRateCell, { props: { account, intervalMinutes: 30, now: Date.now() } })
const wrapper = mount(UpstreamBillingRateCell, { props: { account, now: Date.now() } })
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.stale')
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale')
await wrapper.setProps({
account: makeAccount({
@@ -140,7 +140,7 @@ describe('UpstreamBillingRateCell', () => {
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.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale')
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.stale')
await wrapper.setProps({
@@ -159,13 +159,111 @@ describe('UpstreamBillingRateCell', () => {
}
})
})
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale')
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.stale')
})
it('shows stale snapshot details, local next probe time, and the account probe state', async () => {
const wrapper = mount(UpstreamBillingRateCell, {
attachTo: document.body,
props: {
account: makeAccount({
extra: {
upstream_billing_probe_enabled: true,
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-13T01:00:00Z'
}
}
}),
now: Date.now()
}
})
expect(wrapper.getComponent(HelpTooltip).props('widthClass')).toBe('w-max max-w-[calc(100vw-2rem)]')
await wrapper.get('[data-testid="upstream-billing-details"]').trigger('mouseenter')
await flushPromises()
const tooltips = document.body.querySelectorAll('[role="tooltip"]')
const tooltip = tooltips[tooltips.length - 1] as HTMLElement
expect(tooltip.textContent).toContain('admin.accounts.upstreamBilling.lastDetectedRate:0.9')
expect(tooltip.textContent).toContain('admin.accounts.upstreamBilling.lastDetectedAt:')
expect(tooltip.textContent).toContain('admin.accounts.upstreamBilling.elapsedSince:admin.accounts.upstreamBilling.hoursAgo:2')
expect(tooltip.textContent).toContain('admin.accounts.upstreamBilling.nextProbeAt:')
expect(tooltip.textContent).not.toContain('admin.accounts.upstreamBilling.stale')
expect(tooltip.querySelector('[data-testid="upstream-billing-probe-state"] span')?.className).toContain('text-emerald-400')
await wrapper.setProps({
account: makeAccount({
extra: {
upstream_billing_probe_enabled: false,
upstream_billing_probe: {
status: 'unsupported',
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T01:00:00Z'
}
}
})
})
expect(tooltip.querySelector('[data-testid="upstream-billing-next-probe"]')).toBeNull()
expect(tooltip.querySelector('[data-testid="upstream-billing-probe-state"] span')?.className).toContain('text-red-400')
wrapper.unmount()
})
it('stacks the global-off state below the account state and hides it when globally enabled', async () => {
const wrapper = mount(UpstreamBillingRateCell, {
attachTo: document.body,
props: {
account: makeAccount({
extra: {
upstream_billing_probe_enabled: true,
upstream_billing_probe: {
status: 'unsupported',
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T01:00:00Z'
}
}
}),
globalProbeEnabled: false,
now: Date.now()
}
})
await wrapper.get('[data-testid="upstream-billing-details"]').trigger('mouseenter')
await flushPromises()
const tooltips = document.body.querySelectorAll('[role="tooltip"]')
const tooltip = tooltips[tooltips.length - 1] as HTMLElement
const accountState = tooltip.querySelector('[data-testid="upstream-billing-probe-state"]')
const globalState = tooltip.querySelector('[data-testid="upstream-billing-global-probe-state"]')
expect(accountState?.querySelector('span')?.className).toContain('text-emerald-400')
expect(globalState?.textContent).toContain('admin.accounts.upstreamBilling.globalProbeState')
expect(globalState?.querySelector('span')?.className).toContain('text-red-400')
expect(tooltip.querySelector('[data-testid="upstream-billing-next-probe"]')).toBeNull()
await wrapper.setProps({ globalProbeEnabled: true })
expect(tooltip.querySelector('[data-testid="upstream-billing-global-probe-state"]')).toBeNull()
expect(tooltip.querySelector('[data-testid="upstream-billing-next-probe"]')).not.toBeNull()
await wrapper.setProps({
globalProbeEnabled: false,
account: makeAccount({
extra: { upstream_billing_probe_enabled: false }
})
})
expect(accountState?.querySelector('span')?.className).toContain('text-red-400')
expect(tooltip.querySelector('[data-testid="upstream-billing-global-probe-state"]')).not.toBeNull()
expect(tooltip.querySelector('[data-testid="upstream-billing-next-probe"]')).toBeNull()
wrapper.unmount()
})
it('emits manual probe commands only for eligible accounts', async () => {
const wrapper = mount(UpstreamBillingRateCell, {
props: { account: makeAccount(), intervalMinutes: 30, now: Date.now() }
props: { account: makeAccount(), now: Date.now() }
})
await wrapper.get('[data-testid="upstream-billing-probe"]').trigger('click')
expect(wrapper.emitted('probe')).toHaveLength(1)
@@ -199,7 +297,6 @@ describe('UpstreamBillingRateCell', () => {
peak_rate_enabled: false,
effective_rate_multiplier: -1
}),
intervalMinutes: 30,
now: Date.now()
}
})
@@ -208,11 +305,13 @@ describe('UpstreamBillingRateCell', () => {
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('-')
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale')
await wrapper.setProps({ account: malformedAccount({}, { received_at: '2026-07-13T00:31:00Z' }) })
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('0.6x')
await wrapper.setProps({ account: malformedAccount({}, { received_at: '2026-07-13T00:36:00Z' }) })
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale')
await wrapper.setProps({ account: malformedAccount({}, { fresh_until: '2026-07-12T23:59:00Z' }) })
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale')
await wrapper.setProps({
account: makeAccount({
@@ -226,8 +325,31 @@ describe('UpstreamBillingRateCell', () => {
}
})
})
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.failed')
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.failed')
expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.stale')
})
it('uses unsupported as the primary tooltip trigger without a dash', () => {
const wrapper = mount(UpstreamBillingRateCell, {
props: {
account: makeAccount({
extra: {
upstream_billing_probe: {
status: 'unsupported',
last_attempt_at: '2026-07-13T00:00:00Z',
next_probe_at: '2026-07-13T00:30:00Z',
last_error: 'unsupported'
}
}
}),
now: Date.now()
}
})
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe(
'admin.accounts.upstreamBilling.unsupported'
)
expect(wrapper.text()).not.toContain('-admin.accounts.upstreamBilling.unsupported')
})
})
+30 -30
View File
@@ -130,38 +130,38 @@
]"
@click="column.sortable && handleSort(column.key)"
>
<slot
:name="`header-${column.key}`"
:column="column"
:sort-key="sortKey"
:sort-order="sortOrder"
>
<div :class="['flex items-center space-x-1', getHeaderContentAlignmentClass(column)]">
<div :class="['flex items-center space-x-1', getHeaderContentAlignmentClass(column)]">
<slot
:name="`header-${column.key}`"
:column="column"
:sort-key="sortKey"
:sort-order="sortOrder"
>
<span>{{ column.label }}</span>
<span
v-if="column.sortable"
class="inline-flex h-5 w-4 flex-col items-center justify-center"
aria-hidden="true"
</slot>
<span
v-if="column.sortable"
class="inline-flex h-5 w-4 flex-col items-center justify-center"
aria-hidden="true"
>
<svg
class="h-2.5 w-2.5"
:class="getSortIndicatorClass(column.key, 'asc')"
fill="currentColor"
viewBox="0 0 10 10"
>
<svg
class="h-2.5 w-2.5"
:class="getSortIndicatorClass(column.key, 'asc')"
fill="currentColor"
viewBox="0 0 10 10"
>
<path d="M5 2L1.5 6.5h7L5 2z" />
</svg>
<svg
class="-mt-0.5 h-2.5 w-2.5"
:class="getSortIndicatorClass(column.key, 'desc')"
fill="currentColor"
viewBox="0 0 10 10"
>
<path d="M5 8L1.5 3.5h7L5 8z" />
</svg>
</span>
</div>
</slot>
<path d="M5 2L1.5 6.5h7L5 2z" />
</svg>
<svg
class="-mt-0.5 h-2.5 w-2.5"
:class="getSortIndicatorClass(column.key, 'desc')"
fill="currentColor"
viewBox="0 0 10 10"
>
<path d="M5 8L1.5 3.5h7L5 8z" />
</svg>
</span>
</div>
</th>
</tr>
</thead>
@@ -60,12 +60,16 @@ describe('DataTable', () => {
],
defaultSortKey: 'name',
defaultSortOrder: 'asc'
},
slots: {
'header-name': '<span data-test="custom-name-header">Name</span>'
}
})
await wrapper.vm.$nextTick()
const nameHeader = wrapper.findAll('th')[0]
expect(nameHeader.find('[data-test="custom-name-header"]').exists()).toBe(true)
expect(nameHeader.attributes('aria-sort')).toBe('ascending')
expect(nameHeader.findAll('svg')).toHaveLength(2)
expect(nameHeader.findAll('svg')[0].classes()).toContain('text-primary-600')