mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 00:10:31 +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')
|
||||
|
||||
@@ -175,10 +175,8 @@ export default {
|
||||
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.',
|
||||
autoProbe: 'Automatically probe upstream declared rate',
|
||||
autoProbeHint: 'Probe this account\'s upstream declared rate on the global interval when global probing is enabled.',
|
||||
manualProbe: 'Probe upstream rate now',
|
||||
stale: 'Stale',
|
||||
unsupported: 'Unsupported',
|
||||
@@ -190,8 +188,18 @@ export default {
|
||||
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',
|
||||
nextProbeAt: 'Next probe: {value}',
|
||||
lastDetectedRate: 'Last detected rate: {value}x',
|
||||
lastDetectedAt: 'Last detected: {value}',
|
||||
elapsedSince: 'Elapsed: {value}',
|
||||
justNow: 'less than 1 minute',
|
||||
minutesAgo: '{count} minutes',
|
||||
hoursAgo: '{count} hours',
|
||||
daysAgo: '{count} days',
|
||||
accountProbeState: 'Automatic detection for this account:',
|
||||
globalProbeState: 'Global probe switch:',
|
||||
enabled: 'On',
|
||||
disabled: 'Off',
|
||||
probeFailed: 'Failed to probe upstream rate',
|
||||
noEligibleAccounts: 'Select OpenAI API key accounts',
|
||||
batchLimit: 'A batch can probe at most 20 accounts',
|
||||
|
||||
@@ -316,6 +316,16 @@ export default {
|
||||
allowUngroupedKey: 'Allow Ungrouped Key Scheduling',
|
||||
allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.'
|
||||
},
|
||||
upstreamBillingProbe: {
|
||||
title: 'Upstream Rate Auto Detection',
|
||||
description: 'Periodically retrieve billing rates declared by upstream Sub2API sites connected to OpenAI API keys.',
|
||||
enabled: 'Enable global auto detection',
|
||||
enabledHint: 'When enabled, scheduled detection runs only for accounts that also enable automatic detection. Disabling stops all scheduled detection; manual detection remains available.',
|
||||
intervalMinutes: 'Detection interval (minutes)',
|
||||
intervalHint: 'Range: 5–1440 minutes. A successful result remains valid for two detection intervals.',
|
||||
saved: 'Upstream rate auto detection settings saved',
|
||||
saveFailed: 'Failed to save upstream rate auto detection settings'
|
||||
},
|
||||
gatewayForwarding: {
|
||||
title: 'Request Forwarding',
|
||||
description: 'Control how requests are forwarded to upstream OAuth accounts',
|
||||
|
||||
@@ -131,10 +131,8 @@ export default {
|
||||
usageWindowsHint: '“5h / 7d”是上游账号(如 OpenAI ChatGPT、Claude)官方的滚动用量窗口限制,由上游对账号设定,并非 sub2api 配置,也与你映射的模型无关。窗口滚动到期后用量会自动重置,无法在 sub2api 端解除该限制。',
|
||||
upstreamBilling: {
|
||||
trustWarning: '此倍率由上游站点针对当前 API Key 自行声明。Sub2API 无法验证该值是否与实际扣费一致;上游站点或中间代理可能返回伪造、过期或被篡改的数据。请结合账单、余额变化和实际用量自行核验。',
|
||||
autoProbeSettings: '上游倍率自动探测',
|
||||
intervalMinutes: '探测周期(分钟)',
|
||||
autoProbe: '自动探测',
|
||||
autoProbeHint: '启用后按全局探测周期查询此账号;全局探测关闭时不会执行。',
|
||||
autoProbe: '自动探测上游声明倍率',
|
||||
autoProbeHint: '启用后按全局探测周期查询此账号的上游声明倍率;全局探测关闭时不会执行。',
|
||||
manualProbe: '立即探测上游倍率',
|
||||
stale: '已过期',
|
||||
unsupported: '不支持',
|
||||
@@ -146,8 +144,18 @@ export default {
|
||||
noPeakRate: '高峰倍率:未启用',
|
||||
effectiveRate: '当前倍率:{value}x',
|
||||
updatedAt: '更新时间:{value}',
|
||||
settingsSaved: '上游倍率探测设置已保存',
|
||||
settingsFailed: '保存上游倍率探测设置失败',
|
||||
nextProbeAt: '下一次探测:{value}',
|
||||
lastDetectedRate: '上次探测倍率:{value}x',
|
||||
lastDetectedAt: '上次探测时间:{value}',
|
||||
elapsedSince: '已过去:{value}',
|
||||
justNow: '不足 1 分钟',
|
||||
minutesAgo: '{count} 分钟',
|
||||
hoursAgo: '{count} 小时',
|
||||
daysAgo: '{count} 天',
|
||||
accountProbeState: '当前账号自动检测:',
|
||||
globalProbeState: '全局探测开关:',
|
||||
enabled: '打开',
|
||||
disabled: '关闭',
|
||||
probeFailed: '探测上游倍率失败',
|
||||
noEligibleAccounts: '请选择 OpenAI API Key 账号',
|
||||
batchLimit: '每次最多探测 20 个账号',
|
||||
|
||||
@@ -309,6 +309,16 @@ export default {
|
||||
allowUngroupedKey: '允许未分组 Key 调度',
|
||||
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。'
|
||||
},
|
||||
upstreamBillingProbe: {
|
||||
title: '上游倍率自动探测',
|
||||
description: '定期获取 OpenAI API Key 所连接上游 Sub2API 站点声明的计费倍率。',
|
||||
enabled: '启用全局自动探测',
|
||||
enabledHint: '开启后,仅对账号自身已启用自动检测的账号执行定时探测;关闭后停止所有定时探测,手动探测不受影响。',
|
||||
intervalMinutes: '探测周期(分钟)',
|
||||
intervalHint: '范围 5–1440 分钟。成功探测结果的有效期为两个探测周期。',
|
||||
saved: '上游倍率自动探测设置已保存',
|
||||
saveFailed: '保存上游倍率自动探测设置失败'
|
||||
},
|
||||
gatewayForwarding: {
|
||||
title: '请求转发行为',
|
||||
description: '控制请求转发到上游 OAuth 账号时的行为',
|
||||
|
||||
@@ -1206,6 +1206,7 @@ export interface CreateAccountRequest {
|
||||
group_ids?: number[]
|
||||
expires_at?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
upstream_billing_probe_enabled?: boolean
|
||||
confirm_mixed_channel_risk?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -132,41 +132,6 @@
|
||||
<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">
|
||||
@@ -375,15 +340,17 @@
|
||||
</span>
|
||||
</template>
|
||||
<template #header-upstream_billing_rate="{ column }">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ column.label }}</span>
|
||||
<HelpTooltip :content="t('admin.accounts.upstreamBilling.trustWarning')" width-class="w-80" />
|
||||
<span @click.stop>
|
||||
<HelpTooltip :content="t('admin.accounts.upstreamBilling.trustWarning')" width-class="w-80" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-upstream_billing_rate="{ row }">
|
||||
<UpstreamBillingRateCell
|
||||
:account="row"
|
||||
:interval-minutes="upstreamBillingProbeSettings.interval_minutes"
|
||||
:global-probe-enabled="upstreamBillingProbeGloballyEnabled"
|
||||
:now="upstreamBillingNow"
|
||||
:probing="probingUpstreamBilling.has(row.id)"
|
||||
@probe="handleProbeUpstreamBilling(row)"
|
||||
@@ -512,7 +479,6 @@ 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'
|
||||
@@ -541,7 +507,7 @@ import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSettings, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -619,14 +585,10 @@ 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 upstreamBillingProbeGloballyEnabled = ref<boolean | undefined>(undefined)
|
||||
const upstreamBillingNow = ref(Date.now())
|
||||
let lastUpstreamBillingSortRefreshMinute = -1
|
||||
useIntervalFn(() => { upstreamBillingNow.value = Date.now() }, 60_000)
|
||||
|
||||
// Account tools dropdown
|
||||
@@ -653,6 +615,7 @@ const ACCOUNT_SORTABLE_KEYS = new Set([
|
||||
'schedulable',
|
||||
'priority',
|
||||
'rate_multiplier',
|
||||
'upstream_billing_rate',
|
||||
'last_used_at',
|
||||
'created_at',
|
||||
'expires_at'
|
||||
@@ -968,8 +931,15 @@ const resetAutoRefreshCache = () => {
|
||||
|
||||
const isFirstLoad = ref(true)
|
||||
|
||||
function markUpstreamBillingSortRefresh() {
|
||||
if (sortState.sort_by === 'upstream_billing_rate') {
|
||||
lastUpstreamBillingSortRefreshMinute = Math.floor(Date.now() / 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
const requestParams = params as any
|
||||
markUpstreamBillingSortRefresh()
|
||||
syncAccountListDerivedParams()
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
@@ -986,6 +956,7 @@ const load = async () => {
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
markUpstreamBillingSortRefresh()
|
||||
syncAccountListDerivedParams()
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
@@ -994,6 +965,19 @@ const reload = async () => {
|
||||
await refreshTodayStatsBatch()
|
||||
}
|
||||
|
||||
const refreshUpstreamBillingSortedList = async (force = false) => {
|
||||
if (sortState.sort_by !== 'upstream_billing_rate') return
|
||||
|
||||
const minute = Math.floor(upstreamBillingNow.value / 60_000)
|
||||
if (!force && lastUpstreamBillingSortRefreshMinute === minute) return
|
||||
lastUpstreamBillingSortRefreshMinute = minute
|
||||
try {
|
||||
await reload()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh upstream billing sort:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedReload = () => {
|
||||
syncAccountListDerivedParams()
|
||||
hasPendingListSync.value = false
|
||||
@@ -1033,6 +1017,9 @@ const handleSort = (key: string, order: AccountSortOrder) => {
|
||||
}
|
||||
|
||||
watch(loading, (isLoading, wasLoading) => {
|
||||
if (wasLoading && !isLoading) {
|
||||
upstreamBillingNow.value = Date.now()
|
||||
}
|
||||
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
|
||||
pendingTodayStatsRefresh.value = false
|
||||
refreshTodayStatsBatch().catch((error) => {
|
||||
@@ -1041,6 +1028,12 @@ watch(loading, (isLoading, wasLoading) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(upstreamBillingNow, () => {
|
||||
if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return
|
||||
if (typeof document !== 'undefined' && document.hidden) return
|
||||
void refreshUpstreamBillingSortedList()
|
||||
})
|
||||
|
||||
const isAnyModalOpen = computed(() => {
|
||||
return (
|
||||
showCreate.value ||
|
||||
@@ -1152,7 +1145,9 @@ const refreshAccountsIncrementally = async () => {
|
||||
pagination.pages = result.data.pages || 0
|
||||
mergeAccountsIncrementally(result.data.items || [])
|
||||
hasPendingListSync.value = false
|
||||
markUpstreamBillingSortRefresh()
|
||||
}
|
||||
upstreamBillingNow.value = Date.now()
|
||||
|
||||
await refreshTodayStatsBatch()
|
||||
} catch (error) {
|
||||
@@ -1163,11 +1158,20 @@ const refreshAccountsIncrementally = async () => {
|
||||
}
|
||||
|
||||
const handleManualRefresh = async () => {
|
||||
await load()
|
||||
await Promise.all([load(), loadUpstreamBillingProbeGlobalState()])
|
||||
// Force usage cells to refetch /usage on explicit user refresh.
|
||||
usageManualRefreshToken.value += 1
|
||||
}
|
||||
|
||||
const loadUpstreamBillingProbeGlobalState = async () => {
|
||||
try {
|
||||
const settings = await adminAPI.accounts.getUpstreamBillingProbeSettings()
|
||||
upstreamBillingProbeGloballyEnabled.value = settings.enabled
|
||||
} catch (error) {
|
||||
console.error('Failed to load upstream billing probe settings:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closeAccountToolsDropdown = () => {
|
||||
showAccountToolsDropdown.value = false
|
||||
}
|
||||
@@ -1197,31 +1201,6 @@ 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()
|
||||
@@ -1397,7 +1376,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: 'upstream_billing_rate', label: t('admin.accounts.columns.upstreamBillingRate'), sortable: true },
|
||||
{ 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 },
|
||||
@@ -1521,9 +1500,14 @@ const handleBulkProbeUpstreamBilling = async () => {
|
||||
accountIDs.forEach(id => probingUpstreamBilling.add(id))
|
||||
try {
|
||||
const results = await adminAPI.accounts.probeUpstreamBillingBatch(accountIDs)
|
||||
let patched = false
|
||||
results.forEach(result => {
|
||||
if (result.snapshot) patchUpstreamBillingSnapshot(result.account_id, result.snapshot)
|
||||
if (result.snapshot) {
|
||||
patchUpstreamBillingSnapshot(result.account_id, result.snapshot)
|
||||
patched = true
|
||||
}
|
||||
})
|
||||
if (patched) await refreshUpstreamBillingSortedList(true)
|
||||
const failed = results.filter(result => result.error).length
|
||||
if (failed > 0) {
|
||||
appStore.showError(t('admin.accounts.upstreamBilling.batchPartial', { success: results.length - failed, failed }))
|
||||
@@ -1789,6 +1773,8 @@ const patchAccountInList = (updatedAccount: Account) => {
|
||||
const patchUpstreamBillingSnapshot = (accountID: number, snapshot: UpstreamBillingProbeSnapshot) => {
|
||||
const account = accounts.value.find(item => item.id === accountID)
|
||||
if (!account) return
|
||||
markUpstreamBillingSortRefresh()
|
||||
upstreamBillingNow.value = Date.now()
|
||||
patchAccountInList({
|
||||
...account,
|
||||
extra: { ...account.extra, upstream_billing_probe: snapshot }
|
||||
@@ -1799,7 +1785,10 @@ const handleProbeUpstreamBilling = async (account: Account) => {
|
||||
probingUpstreamBilling.add(account.id)
|
||||
try {
|
||||
const result = await adminAPI.accounts.probeUpstreamBilling(account.id)
|
||||
if (result.snapshot) patchUpstreamBillingSnapshot(account.id, result.snapshot)
|
||||
if (result.snapshot) {
|
||||
patchUpstreamBillingSnapshot(account.id, result.snapshot)
|
||||
await refreshUpstreamBillingSortedList(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to probe upstream billing:', error)
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.upstreamBilling.probeFailed')))
|
||||
@@ -2062,7 +2051,7 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
loadUpstreamBillingProbeSettings()
|
||||
loadUpstreamBillingProbeGlobalState()
|
||||
try {
|
||||
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
|
||||
proxies.value = p
|
||||
|
||||
@@ -4043,6 +4043,92 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upstream Billing Probe Settings -->
|
||||
<div class="card" data-testid="upstream-billing-probe-settings">
|
||||
<div
|
||||
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.upstreamBillingProbe.title") }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.upstreamBillingProbe.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div
|
||||
v-if="upstreamBillingProbeLoading"
|
||||
class="flex items-center gap-2 text-gray-500"
|
||||
>
|
||||
<div
|
||||
class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"
|
||||
></div>
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.upstreamBillingProbe.enabled") }}
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.upstreamBillingProbe.enabledHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="upstreamBillingProbeForm.enabled"
|
||||
:aria-label="t('admin.settings.upstreamBillingProbe.enabled')"
|
||||
data-testid="upstream-billing-probe-enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="upstreamBillingProbeForm.enabled"
|
||||
class="border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
for="upstream-billing-probe-interval"
|
||||
>
|
||||
{{ t("admin.settings.upstreamBillingProbe.intervalMinutes") }}
|
||||
</label>
|
||||
<input
|
||||
id="upstream-billing-probe-interval"
|
||||
v-model.number="upstreamBillingProbeForm.interval_minutes"
|
||||
type="number"
|
||||
min="5"
|
||||
max="1440"
|
||||
class="input w-32"
|
||||
data-testid="upstream-billing-probe-interval"
|
||||
@keydown.enter.prevent="saveUpstreamBillingProbeSettings"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.upstreamBillingProbe.intervalHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="upstreamBillingProbeSaving"
|
||||
data-testid="upstream-billing-probe-save"
|
||||
@click="saveUpstreamBillingProbeSettings"
|
||||
>
|
||||
{{
|
||||
upstreamBillingProbeSaving
|
||||
? t("common.saving")
|
||||
: t("common.save")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Scheduling Settings -->
|
||||
<div class="card">
|
||||
<div
|
||||
@@ -7678,6 +7764,14 @@ const adminApiKeyOperating = ref(false);
|
||||
const newAdminApiKey = ref("");
|
||||
const subscriptionGroups = ref<AdminGroup[]>([]);
|
||||
|
||||
// Upstream billing probe state
|
||||
const upstreamBillingProbeLoading = ref(true);
|
||||
const upstreamBillingProbeSaving = ref(false);
|
||||
const upstreamBillingProbeForm = reactive({
|
||||
enabled: true,
|
||||
interval_minutes: 30,
|
||||
});
|
||||
|
||||
// Overload Cooldown (529) 状态
|
||||
const overloadCooldownLoading = ref(true);
|
||||
const overloadCooldownSaving = ref(false);
|
||||
@@ -10107,6 +10201,40 @@ function copyNewKey() {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadUpstreamBillingProbeSettings() {
|
||||
upstreamBillingProbeLoading.value = true;
|
||||
try {
|
||||
Object.assign(
|
||||
upstreamBillingProbeForm,
|
||||
await adminAPI.accounts.getUpstreamBillingProbeSettings(),
|
||||
);
|
||||
} catch (_error: unknown) {
|
||||
// Keep defaults when this optional setting cannot be loaded.
|
||||
} finally {
|
||||
upstreamBillingProbeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUpstreamBillingProbeSettings() {
|
||||
upstreamBillingProbeSaving.value = true;
|
||||
try {
|
||||
const updated = await adminAPI.accounts.updateUpstreamBillingProbeSettings({
|
||||
...upstreamBillingProbeForm,
|
||||
});
|
||||
Object.assign(upstreamBillingProbeForm, updated);
|
||||
appStore.showSuccess(t("admin.settings.upstreamBillingProbe.saved"));
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(
|
||||
extractApiErrorMessage(
|
||||
error,
|
||||
t("admin.settings.upstreamBillingProbe.saveFailed"),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
upstreamBillingProbeSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Overload Cooldown 方法
|
||||
async function loadOverloadCooldownSettings() {
|
||||
overloadCooldownLoading.value = true;
|
||||
@@ -10803,6 +10931,7 @@ onMounted(() => {
|
||||
loadSettings();
|
||||
loadSubscriptionGroups();
|
||||
loadAdminApiKey();
|
||||
loadUpstreamBillingProbeSettings();
|
||||
loadOverloadCooldownSettings();
|
||||
loadRateLimit429CooldownSettings();
|
||||
loadStreamTimeoutSettings();
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
listAccounts,
|
||||
listWithEtag,
|
||||
getBatchTodayStats,
|
||||
getUpstreamBillingProbeSettings,
|
||||
getAllProxies,
|
||||
getAllGroups,
|
||||
probeUpstreamBillingBatch
|
||||
@@ -14,6 +15,7 @@ const {
|
||||
listAccounts: vi.fn(),
|
||||
listWithEtag: vi.fn(),
|
||||
getBatchTodayStats: vi.fn(),
|
||||
getUpstreamBillingProbeSettings: vi.fn(),
|
||||
getAllProxies: vi.fn(),
|
||||
getAllGroups: vi.fn(),
|
||||
probeUpstreamBillingBatch: vi.fn()
|
||||
@@ -25,7 +27,7 @@ vi.mock('@/api/admin', () => ({
|
||||
list: listAccounts,
|
||||
listWithEtag,
|
||||
getBatchTodayStats,
|
||||
getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: true, interval_minutes: 30 }),
|
||||
getUpstreamBillingProbeSettings,
|
||||
delete: vi.fn(),
|
||||
batchClearError: vi.fn(),
|
||||
batchRefresh: vi.fn(),
|
||||
@@ -106,6 +108,7 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
listAccounts.mockReset()
|
||||
listWithEtag.mockReset()
|
||||
getBatchTodayStats.mockReset()
|
||||
getUpstreamBillingProbeSettings.mockReset()
|
||||
getAllProxies.mockReset()
|
||||
getAllGroups.mockReset()
|
||||
probeUpstreamBillingBatch.mockReset()
|
||||
@@ -123,6 +126,7 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
data: null
|
||||
})
|
||||
getBatchTodayStats.mockResolvedValue({ stats: {} })
|
||||
getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30 })
|
||||
getAllProxies.mockResolvedValue([])
|
||||
getAllGroups.mockResolvedValue([])
|
||||
probeUpstreamBillingBatch.mockResolvedValue([])
|
||||
@@ -242,6 +246,75 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the loaded global probe state to every upstream billing cell', async () => {
|
||||
listAccounts.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'upstream',
|
||||
platform: 'openai',
|
||||
type: 'apikey',
|
||||
status: 'active',
|
||||
schedulable: true,
|
||||
created_at: '2026-07-13T00:00:00Z',
|
||||
updated_at: '2026-07-13T00:00:00Z'
|
||||
}
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1
|
||||
})
|
||||
getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: false, interval_minutes: 30 })
|
||||
|
||||
const wrapper = mount(AccountsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
TablePageLayout: { template: '<div><slot name="table" /></div>' },
|
||||
DataTable: {
|
||||
props: ['data'],
|
||||
template: '<div><div v-for="row in data" :key="row.id"><slot name="cell-upstream_billing_rate" :row="row" /></div></div>'
|
||||
},
|
||||
UpstreamBillingRateCell: {
|
||||
props: ['globalProbeEnabled'],
|
||||
template: '<span data-test="upstream-billing-cell" :data-global-enabled="String(globalProbeEnabled)"></span>'
|
||||
},
|
||||
Pagination: true,
|
||||
ConfirmDialog: true,
|
||||
AccountTableActions: true,
|
||||
AccountTableFilters: true,
|
||||
AccountBulkActionsBar: true,
|
||||
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: true,
|
||||
PlatformTypeBadge: true,
|
||||
AccountCapacityCell: true,
|
||||
AccountStatusIndicator: true,
|
||||
AccountTodayStatsCell: true,
|
||||
AccountGroupsCell: true,
|
||||
AccountUsageCell: true,
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(getUpstreamBillingProbeSettings).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.get('[data-test="upstream-billing-cell"]').attributes('data-global-enabled')).toBe('false')
|
||||
})
|
||||
|
||||
it('submits selected account IDs from every page for backend eligibility checks', async () => {
|
||||
const account = (id: number) => ({
|
||||
id,
|
||||
@@ -302,4 +375,75 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
|
||||
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7, 11])
|
||||
})
|
||||
|
||||
it('reloads the server-sorted list after a batch probe changes a snapshot', async () => {
|
||||
localStorage.setItem('account-table-sort', JSON.stringify({ key: 'upstream_billing_rate', order: 'asc' }))
|
||||
const account = (id: number) => ({
|
||||
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: 1, page: 1, page_size: 20, pages: 1 })
|
||||
.mockResolvedValueOnce({ items: [account(7)], total: 1, page: 1, page_size: 20, pages: 1 })
|
||||
probeUpstreamBillingBatch.mockResolvedValue([
|
||||
{
|
||||
account_id: 7,
|
||||
snapshot: {
|
||||
status: 'ok',
|
||||
data: { effective_rate_multiplier: 0.5 },
|
||||
last_attempt_at: '2026-07-13T00:00:00Z',
|
||||
next_probe_at: '2026-07-13T00:30:00Z'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const wrapper = mount(AccountsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
TablePageLayout: { template: '<div><slot name="table" /></div>' },
|
||||
DataTable: DataTableStub,
|
||||
AccountBulkActionsBar: AccountBulkActionsBarStub,
|
||||
AccountTableActions: true,
|
||||
AccountTableFilters: true,
|
||||
AccountActionMenu: true,
|
||||
Pagination: true,
|
||||
ConfirmDialog: true,
|
||||
ImportDataModal: true,
|
||||
ReAuthAccountModal: true,
|
||||
AccountTestModal: true,
|
||||
AccountStatsModal: true,
|
||||
ScheduledTestsPanel: true,
|
||||
SyncFromCrsModal: true,
|
||||
TempUnschedStatusModal: true,
|
||||
ErrorPassthroughRulesModal: true,
|
||||
TLSFingerprintProfilesModal: true,
|
||||
CreateAccountModal: true,
|
||||
EditAccountModal: true,
|
||||
BulkEditAccountModal: 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="probe-upstream-billing"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7])
|
||||
expect(listAccounts).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -176,5 +176,7 @@ describe('admin AccountsView usage windows hint', () => {
|
||||
expect(wrapper.findAll('[data-test="usage-windows-hint"]').some(node =>
|
||||
node.text() === 'admin.accounts.upstreamBilling.trustWarning'
|
||||
)).toBe(true)
|
||||
const columns = wrapper.getComponent(DataTableStub).props('columns') as Array<{ key: string; sortable: boolean }>
|
||||
expect(columns.find(column => column.key === 'upstream_billing_rate')?.sortable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,8 @@ const {
|
||||
getStreamTimeoutSettings,
|
||||
getRectifierSettings,
|
||||
getBetaPolicySettings,
|
||||
getUpstreamBillingProbeSettings,
|
||||
updateUpstreamBillingProbeSettings,
|
||||
getGroups,
|
||||
listProxies,
|
||||
getProviders,
|
||||
@@ -38,6 +40,11 @@ const {
|
||||
getStreamTimeoutSettings: vi.fn(),
|
||||
getRectifierSettings: vi.fn(),
|
||||
getBetaPolicySettings: vi.fn(),
|
||||
getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
interval_minutes: 30,
|
||||
}),
|
||||
updateUpstreamBillingProbeSettings: vi.fn().mockImplementation(async (payload) => payload),
|
||||
getGroups: vi.fn(),
|
||||
listProxies: vi.fn(),
|
||||
getProviders: vi.fn(),
|
||||
@@ -67,6 +74,10 @@ vi.mock("@/api", () => ({
|
||||
getRectifierSettings,
|
||||
getBetaPolicySettings,
|
||||
},
|
||||
accounts: {
|
||||
getUpstreamBillingProbeSettings,
|
||||
updateUpstreamBillingProbeSettings,
|
||||
},
|
||||
groups: {
|
||||
getAll: getGroups,
|
||||
},
|
||||
@@ -184,6 +195,14 @@ vi.mock("vue-i18n", async () => {
|
||||
"admin.settings.openaiExperimentalScheduler.upstreamCostWeight": "计费倍率",
|
||||
"admin.settings.openaiExperimentalScheduler.previousResponseWeight": "previous_response 粘性",
|
||||
"admin.settings.openaiExperimentalScheduler.sessionStickyWeight": "session_hash 粘性",
|
||||
"admin.settings.upstreamBillingProbe.title": "上游倍率自动探测",
|
||||
"admin.settings.upstreamBillingProbe.description": "定期获取 OpenAI API Key 所连接上游 Sub2API 站点声明的计费倍率。",
|
||||
"admin.settings.upstreamBillingProbe.enabled": "启用全局自动探测",
|
||||
"admin.settings.upstreamBillingProbe.enabledHint": "开启后,仅对账号自身已启用自动检测的账号执行定时探测。",
|
||||
"admin.settings.upstreamBillingProbe.intervalMinutes": "探测周期(分钟)",
|
||||
"admin.settings.upstreamBillingProbe.intervalHint": "范围 5–1440 分钟。",
|
||||
"admin.settings.upstreamBillingProbe.saved": "上游倍率自动探测设置已保存",
|
||||
"admin.settings.upstreamBillingProbe.saveFailed": "保存上游倍率自动探测设置失败",
|
||||
"admin.settings.site.uploadImage": "上传图片",
|
||||
"admin.settings.site.remove": "移除",
|
||||
"admin.settings.platformQuota.platform": "平台",
|
||||
@@ -517,6 +536,16 @@ async function openSecurityTab(wrapper: ReturnType<typeof mountView>) {
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
async function openGatewayTab(wrapper: ReturnType<typeof mountView>) {
|
||||
const gatewayTabButton = wrapper
|
||||
.findAll("button")
|
||||
.find((node) => node.text().includes("admin.settings.tabs.gateway"));
|
||||
|
||||
expect(gatewayTabButton).toBeDefined();
|
||||
await gatewayTabButton?.trigger("click");
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
async function openUsersTab(wrapper: ReturnType<typeof mountView>) {
|
||||
const usersTabButton = wrapper
|
||||
.findAll("button")
|
||||
@@ -540,6 +569,8 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
getStreamTimeoutSettings.mockReset();
|
||||
getRectifierSettings.mockReset();
|
||||
getBetaPolicySettings.mockReset();
|
||||
getUpstreamBillingProbeSettings.mockReset();
|
||||
updateUpstreamBillingProbeSettings.mockReset();
|
||||
getGroups.mockReset();
|
||||
listProxies.mockReset();
|
||||
getProviders.mockReset();
|
||||
@@ -595,6 +626,11 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
getBetaPolicySettings.mockResolvedValue({
|
||||
rules: [],
|
||||
});
|
||||
getUpstreamBillingProbeSettings.mockResolvedValue({
|
||||
enabled: true,
|
||||
interval_minutes: 30,
|
||||
});
|
||||
updateUpstreamBillingProbeSettings.mockImplementation(async (payload) => payload);
|
||||
getGroups.mockResolvedValue([]);
|
||||
listProxies.mockResolvedValue({
|
||||
items: [],
|
||||
@@ -848,6 +884,38 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
|
||||
});
|
||||
|
||||
it("loads and saves upstream billing probe settings from the gateway tab", async () => {
|
||||
getUpstreamBillingProbeSettings.mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
interval_minutes: 45,
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await openGatewayTab(wrapper);
|
||||
|
||||
const card = wrapper.get('[data-testid="upstream-billing-probe-settings"]');
|
||||
expect(card.isVisible()).toBe(true);
|
||||
expect(card.text()).toContain("上游倍率自动探测");
|
||||
expect(
|
||||
(card.get('[data-testid="upstream-billing-probe-enabled"]').element as HTMLInputElement)
|
||||
.checked,
|
||||
).toBe(false);
|
||||
expect(card.find('[data-testid="upstream-billing-probe-interval"]').exists()).toBe(false);
|
||||
|
||||
await card.get('[data-testid="upstream-billing-probe-enabled"]').setValue(true);
|
||||
await card.get('[data-testid="upstream-billing-probe-interval"]').setValue(60);
|
||||
await card.get('[data-testid="upstream-billing-probe-save"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateUpstreamBillingProbeSettings).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
interval_minutes: 60,
|
||||
});
|
||||
expect(showSuccess).toHaveBeenCalledWith("上游倍率自动探测设置已保存");
|
||||
});
|
||||
|
||||
it("places and explains rate controls for both scheduling modes", async () => {
|
||||
const wrapper = mountView();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user