mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
Merge pull request #4478 from yardbirds0/codex/fix-upstream-billing-probe-refresh
fix: 完善上游 Sub2API 倍率探测刷新、展示与账号配置
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user