mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
feat(grok): 吸收调度阈值、配额解析、批量用量与 CLI 身份
从 personal-dev 取优并接线到 feat/grok-complete-integration: - 调度阈值:grok_sched_* 写入、ApplyAccountSchedulingThreshold、 account_scheduling_thresholds 设置与 Settings UI、网关过滤 - 配额头:x-rate-limit 别名、相对秒 reset、tier/entitlement 扩展 - 批量 usage:GetUsageBatch + POST /usage/batch + AccountsView 合并加载 - CLI 身份:xai.cli_identity 统一 pin/UA;service 层 applyGrokCLIHeaders 与 transport 最终改写对齐;media eligibility 模块落地 - UsageCell:月度 cents→USD 与 30d 进度条展示 保留既有 failure taxonomy / sticky / free recovery 等 HEAD cools。
This commit is contained in:
@@ -318,7 +318,12 @@
|
||||
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
|
||||
:today-stats-loading="todayStatsLoading"
|
||||
:manual-refresh-token="usageManualRefreshToken"
|
||||
:batched-usage="usageBatchByAccountId[String(row.id)] ?? null"
|
||||
:batched-usage-error="usageBatchErrorByAccountId[String(row.id)] ?? null"
|
||||
:batched-usage-loading="usageBatchLoadingByAccountId[String(row.id)] === true"
|
||||
:request-batched-usage="isDesktopViewport ? queueBatchedUsage : null"
|
||||
@account-updated="handleAccountUpdated"
|
||||
@usage-loaded="handleAccountUsageLoaded(row.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-proxy="{ row }">
|
||||
@@ -527,7 +532,7 @@ import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
import { getFloatingPanelPosition } from '@/utils/floatingPanel'
|
||||
import { formatMultiplier } from '@/utils/formatters'
|
||||
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, AccountUsageInfo, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -692,6 +697,24 @@ const todayStatsReqSeq = ref(0)
|
||||
const pendingTodayStatsRefresh = ref(false)
|
||||
const usageManualRefreshToken = ref(0)
|
||||
|
||||
const desktopViewportQuery = '(min-width: 768px)'
|
||||
const isDesktopViewport = ref(
|
||||
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
|
||||
)
|
||||
let desktopViewportMediaQuery: MediaQueryList | null = null
|
||||
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null
|
||||
|
||||
const usageBatchByAccountId = ref<Record<string, AccountUsageInfo | null>>({})
|
||||
const usageBatchErrorByAccountId = ref<Record<string, string | null>>({})
|
||||
const usageBatchLoadingByAccountId = ref<Record<string, boolean>>({})
|
||||
const usageBatchRequestTokenByAccountId = ref<Record<string, number>>({})
|
||||
const usageBatchCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||
const USAGE_BATCH_CACHE_TTL = 5 * 60 * 1000
|
||||
const pendingUsageBatchIds = new Set<number>()
|
||||
let usageBatchFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let queuedUsageBatchForce = false
|
||||
let usageBatchRequestToken = 0
|
||||
|
||||
const buildDefaultTodayStats = (): WindowStats => ({
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
@@ -700,6 +723,138 @@ const buildDefaultTodayStats = (): WindowStats => ({
|
||||
user_cost: 0
|
||||
})
|
||||
|
||||
const accountSupportsBatchUsage = (account: Account) => {
|
||||
if (account.platform === 'anthropic') {
|
||||
return account.type === 'oauth' || account.type === 'setup-token'
|
||||
}
|
||||
if (account.platform === 'gemini') return true
|
||||
if (account.platform === 'antigravity') return account.type === 'oauth'
|
||||
if (account.platform === 'openai') return account.type === 'oauth'
|
||||
if (account.platform === 'grok') return account.type === 'oauth'
|
||||
return false
|
||||
}
|
||||
|
||||
const setUsageBatchLoading = (accountID: number, loadingState: boolean) => {
|
||||
usageBatchLoadingByAccountId.value = {
|
||||
...usageBatchLoadingByAccountId.value,
|
||||
[String(accountID)]: loadingState
|
||||
}
|
||||
}
|
||||
|
||||
const setUsageBatchState = (accountID: number, usage: AccountUsageInfo | null, error: string | null) => {
|
||||
const key = String(accountID)
|
||||
usageBatchByAccountId.value = {
|
||||
...usageBatchByAccountId.value,
|
||||
[key]: usage
|
||||
}
|
||||
usageBatchErrorByAccountId.value = {
|
||||
...usageBatchErrorByAccountId.value,
|
||||
[key]: error
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccountUsageLoaded = (accountID: number, usage: AccountUsageInfo) => {
|
||||
if (usageBatchByAccountId.value[String(accountID)] === usage) return
|
||||
setUsageBatchState(accountID, usage, null)
|
||||
}
|
||||
|
||||
const flushQueuedUsageBatch = async () => {
|
||||
usageBatchFlushTimer = null
|
||||
const accountIDs = Array.from(pendingUsageBatchIds)
|
||||
const force = queuedUsageBatchForce
|
||||
pendingUsageBatchIds.clear()
|
||||
queuedUsageBatchForce = false
|
||||
|
||||
if (accountIDs.length === 0) return
|
||||
|
||||
const requestTokensByAccount = accountIDs.reduce<Record<string, number>>((acc, accountID) => {
|
||||
acc[String(accountID)] = usageBatchRequestTokenByAccountId.value[String(accountID)] ?? 0
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
try {
|
||||
const result = await adminAPI.accounts.getBatchUsage(accountIDs, force)
|
||||
|
||||
const usageMap = result.usage ?? {}
|
||||
const errorMap = result.errors ?? {}
|
||||
const now = Date.now()
|
||||
const nextUsage = { ...usageBatchByAccountId.value }
|
||||
const nextErrors = { ...usageBatchErrorByAccountId.value }
|
||||
const nextLoading = { ...usageBatchLoadingByAccountId.value }
|
||||
|
||||
for (const accountID of accountIDs) {
|
||||
const key = String(accountID)
|
||||
if ((usageBatchRequestTokenByAccountId.value[key] ?? 0) !== requestTokensByAccount[key]) {
|
||||
continue
|
||||
}
|
||||
const usage = usageMap[key] ?? null
|
||||
nextUsage[key] = usage
|
||||
nextErrors[key] = errorMap[key] ?? null
|
||||
nextLoading[key] = false
|
||||
if (usage) {
|
||||
usageBatchCache.set(accountID, { data: usage, ts: now })
|
||||
} else {
|
||||
usageBatchCache.delete(accountID)
|
||||
}
|
||||
}
|
||||
|
||||
usageBatchByAccountId.value = nextUsage
|
||||
usageBatchErrorByAccountId.value = nextErrors
|
||||
usageBatchLoadingByAccountId.value = nextLoading
|
||||
} catch (error) {
|
||||
const nextErrors = { ...usageBatchErrorByAccountId.value }
|
||||
const nextLoading = { ...usageBatchLoadingByAccountId.value }
|
||||
for (const accountID of accountIDs) {
|
||||
const key = String(accountID)
|
||||
if ((usageBatchRequestTokenByAccountId.value[key] ?? 0) !== requestTokensByAccount[key]) {
|
||||
continue
|
||||
}
|
||||
nextErrors[key] = 'Failed'
|
||||
nextLoading[key] = false
|
||||
}
|
||||
usageBatchErrorByAccountId.value = nextErrors
|
||||
usageBatchLoadingByAccountId.value = nextLoading
|
||||
console.error('Failed to load account usage batch:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const queueBatchedUsage = (account: Account, options?: { force?: boolean }) => {
|
||||
if (!isDesktopViewport.value) return
|
||||
if (!accountSupportsBatchUsage(account)) return
|
||||
|
||||
const force = options?.force === true
|
||||
const cacheKey = account.id
|
||||
const key = String(cacheKey)
|
||||
|
||||
if (force) {
|
||||
usageBatchCache.delete(cacheKey)
|
||||
} else {
|
||||
const cached = usageBatchCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.ts < USAGE_BATCH_CACHE_TTL) {
|
||||
setUsageBatchState(cacheKey, cached.data, null)
|
||||
setUsageBatchLoading(cacheKey, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
usageBatchErrorByAccountId.value = {
|
||||
...usageBatchErrorByAccountId.value,
|
||||
[key]: null
|
||||
}
|
||||
usageBatchRequestTokenByAccountId.value = {
|
||||
...usageBatchRequestTokenByAccountId.value,
|
||||
[key]: ++usageBatchRequestToken
|
||||
}
|
||||
setUsageBatchLoading(cacheKey, true)
|
||||
pendingUsageBatchIds.add(cacheKey)
|
||||
queuedUsageBatchForce = queuedUsageBatchForce || force
|
||||
|
||||
if (usageBatchFlushTimer !== null) return
|
||||
usageBatchFlushTimer = setTimeout(() => {
|
||||
void flushQueuedUsageBatch()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const refreshTodayStatsBatch = async () => {
|
||||
// Why this checks both columns:
|
||||
// - today_stats column shows dedicated today's metrics.
|
||||
@@ -1084,6 +1239,22 @@ watch(loading, (isLoading, wasLoading) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(accounts, (rows) => {
|
||||
const visibleIDs = new Set(rows.map((row) => String(row.id)))
|
||||
usageBatchByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
usageBatchErrorByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchErrorByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
usageBatchLoadingByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchLoadingByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
usageBatchRequestTokenByAccountId.value = Object.fromEntries(
|
||||
Object.entries(usageBatchRequestTokenByAccountId.value).filter(([key]) => visibleIDs.has(key))
|
||||
)
|
||||
})
|
||||
|
||||
watch(upstreamBillingNow, () => {
|
||||
if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return
|
||||
if (typeof document !== 'undefined' && document.hidden) return
|
||||
@@ -2183,6 +2354,19 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery)
|
||||
isDesktopViewport.value = desktopViewportMediaQuery.matches
|
||||
desktopViewportListener = (event: MediaQueryListEvent) => {
|
||||
isDesktopViewport.value = event.matches
|
||||
}
|
||||
if (typeof desktopViewportMediaQuery.addEventListener === 'function') {
|
||||
desktopViewportMediaQuery.addEventListener('change', desktopViewportListener)
|
||||
} else {
|
||||
desktopViewportMediaQuery.addListener(desktopViewportListener)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
loadUpstreamBillingProbeGlobalState()
|
||||
try {
|
||||
@@ -2205,9 +2389,23 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (usageBatchFlushTimer !== null) {
|
||||
clearTimeout(usageBatchFlushTimer)
|
||||
usageBatchFlushTimer = null
|
||||
}
|
||||
pendingUsageBatchIds.clear()
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleViewportResize)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
if (desktopViewportMediaQuery && desktopViewportListener) {
|
||||
if (typeof desktopViewportMediaQuery.removeEventListener === 'function') {
|
||||
desktopViewportMediaQuery.removeEventListener('change', desktopViewportListener)
|
||||
} else {
|
||||
desktopViewportMediaQuery.removeListener(desktopViewportListener)
|
||||
}
|
||||
}
|
||||
desktopViewportListener = null
|
||||
desktopViewportMediaQuery = null
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4871,6 +4871,78 @@
|
||||
<Toggle v-model="form.allow_ungrouped_key_scheduling" />
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div class="mb-3">
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsTitle",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsDescription",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsGlobalHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsDisabledHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="platform in schedulingThresholdPlatforms"
|
||||
:key="platform"
|
||||
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<label
|
||||
class="font-mono text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ platform }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.scheduling.accountSchedulingThresholdsRangeHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="form.account_scheduling_thresholds[platform]"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
class="input mt-3"
|
||||
:data-testid="`account-scheduling-threshold-${platform}`"
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!form.openai_advanced_scheduler_enabled"
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-5 dark:border-dark-700"
|
||||
@@ -8491,8 +8563,11 @@ import { adminAPI } from "@/api";
|
||||
import {
|
||||
appendAuthSourceDefaultsToUpdateRequest,
|
||||
buildAuthSourceDefaultsState,
|
||||
normalizeAccountSchedulingThresholdsMap,
|
||||
normalizePlatformQuotasMap,
|
||||
sanitizeAccountSchedulingThresholdsMap,
|
||||
sanitizePlatformQuotasMap,
|
||||
SCHEDULING_THRESHOLD_PLATFORMS,
|
||||
defaultWeChatConnectScopesForMode,
|
||||
deriveWeChatConnectStoredMode,
|
||||
normalizeDefaultSubscriptionSettings,
|
||||
@@ -9236,8 +9311,11 @@ type SettingsForm = Omit<
|
||||
openai_advanced_scheduler_weight_session_sticky: string;
|
||||
// 系统全局平台限额 map;form 内始终归一化为全 4 平台对象(模板非空绑定依赖此不变量)
|
||||
default_platform_quotas: DefaultPlatformQuotasMap;
|
||||
account_scheduling_thresholds: ReturnType<typeof normalizeAccountSchedulingThresholdsMap>;
|
||||
};
|
||||
|
||||
const schedulingThresholdPlatforms = SCHEDULING_THRESHOLD_PLATFORMS;
|
||||
|
||||
const form = reactive<SettingsForm>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
@@ -9260,6 +9338,7 @@ const form = reactive<SettingsForm>({
|
||||
login_agreement_documents: defaultLoginAgreementDocuments(),
|
||||
default_balance: 0,
|
||||
default_platform_quotas: normalizePlatformQuotasMap() as DefaultPlatformQuotasMap,
|
||||
account_scheduling_thresholds: normalizeAccountSchedulingThresholdsMap(),
|
||||
affiliate_rebate_rate: 20,
|
||||
affiliate_rebate_freeze_hours: 0,
|
||||
affiliate_rebate_duration_days: 0,
|
||||
@@ -10512,6 +10591,9 @@ async function loadSettings() {
|
||||
: defaultLoginAgreementDocuments();
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(settings.default_platform_quotas);
|
||||
form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap(
|
||||
settings.account_scheduling_thresholds,
|
||||
);
|
||||
form.backend_mode_enabled = settings.backend_mode_enabled;
|
||||
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
|
||||
settings.default_subscriptions,
|
||||
@@ -11186,6 +11268,9 @@ async function saveSettings() {
|
||||
}
|
||||
|
||||
payload.default_platform_quotas = sanitizePlatformQuotasMap(form.default_platform_quotas);
|
||||
payload.account_scheduling_thresholds = sanitizeAccountSchedulingThresholdsMap(
|
||||
form.account_scheduling_thresholds,
|
||||
);
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
|
||||
|
||||
const updated = await settingsStepUp.run(() =>
|
||||
@@ -11199,6 +11284,9 @@ async function saveSettings() {
|
||||
}
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(updated.default_platform_quotas);
|
||||
form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap(
|
||||
updated.account_scheduling_thresholds,
|
||||
);
|
||||
registrationEmailSuffixWhitelistTags.value =
|
||||
normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist,
|
||||
|
||||
Reference in New Issue
Block a user