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