mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
fix(frontend): synchronize pool account state
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||
import { resetCodexCycleUsageWindows } from '@/features/pool/utils/poolCycleStats'
|
||||
|
||||
describe('resetCodexCycleUsageWindows', () => {
|
||||
it('mirrors the backend reset scope and creates zero usage for resettable windows', () => {
|
||||
const windows: QuotaWindowSnapshot[] = [
|
||||
{
|
||||
code: '5h',
|
||||
scope: 'account',
|
||||
window_minutes: 300,
|
||||
usage: null,
|
||||
},
|
||||
{
|
||||
code: 'spark_weekly',
|
||||
scope: 'account',
|
||||
window_minutes: 10_080,
|
||||
usage: { request_count: 3, total_tokens: 30, total_cost_usd: '0.03' },
|
||||
},
|
||||
{
|
||||
code: 'model_daily',
|
||||
scope: 'model',
|
||||
window_minutes: 1_440,
|
||||
usage: { request_count: 4, total_tokens: 40, total_cost_usd: '0.04' },
|
||||
},
|
||||
{
|
||||
code: 'lifetime',
|
||||
scope: 'account',
|
||||
window_minutes: 0,
|
||||
usage: { request_count: 5, total_tokens: 50, total_cost_usd: '0.05' },
|
||||
},
|
||||
]
|
||||
|
||||
const result = resetCodexCycleUsageWindows(windows, 123)
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
usage_reset_at: 123,
|
||||
usage: { request_count: 0, total_tokens: 0, total_cost_usd: '0.00000000' },
|
||||
})
|
||||
expect(result[1]).toBe(windows[1])
|
||||
expect(result[2]).toBe(windows[2])
|
||||
expect(result[3]).toBe(windows[3])
|
||||
})
|
||||
|
||||
it('treats a missing scope as account and ignores empty codes', () => {
|
||||
const windows: QuotaWindowSnapshot[] = [
|
||||
{ code: 'weekly', usage: { request_count: 7 } },
|
||||
{ code: ' ', scope: 'account', usage: { request_count: 8 } },
|
||||
]
|
||||
|
||||
const result = resetCodexCycleUsageWindows(windows, 456)
|
||||
|
||||
expect(result[0].usage?.request_count).toBe(0)
|
||||
expect(result[0].usage_reset_at).toBe(456)
|
||||
expect(result[1]).toBe(windows[1])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PoolKeyDetail } from '@/api/endpoints/pool'
|
||||
import type { RefreshQuotaResult } from '@/api/endpoints/keys'
|
||||
import { mergePoolKeyQuotaSnapshots } from '@/features/pool/utils/poolQuotaRefresh'
|
||||
|
||||
function createKey(keyId: string): PoolKeyDetail {
|
||||
return {
|
||||
key_id: keyId,
|
||||
key_name: keyId,
|
||||
is_active: true,
|
||||
auth_type: 'oauth',
|
||||
api_formats: ['openai:responses'],
|
||||
internal_priority: 0,
|
||||
account_quota: null,
|
||||
cooldown_reason: null,
|
||||
cooldown_ttl_seconds: null,
|
||||
cost_window_usage: 0,
|
||||
cost_limit: null,
|
||||
request_count: 0,
|
||||
total_tokens: 0,
|
||||
total_cost_usd: '0',
|
||||
sticky_sessions: 0,
|
||||
lru_score: null,
|
||||
created_at: null,
|
||||
last_used_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mergePoolKeyQuotaSnapshots', () => {
|
||||
it('merges snapshots from non-success quota results', () => {
|
||||
const keys = [createKey('exhausted'), createKey('invalid'), createKey('unchanged')]
|
||||
const results: RefreshQuotaResult['results'] = [
|
||||
{
|
||||
key_id: 'exhausted',
|
||||
key_name: 'exhausted',
|
||||
status: 'quota_exhausted',
|
||||
quota_snapshot: {
|
||||
code: 'exhausted',
|
||||
exhausted: true,
|
||||
updated_at: 123,
|
||||
},
|
||||
},
|
||||
{
|
||||
key_id: 'invalid',
|
||||
key_name: 'invalid',
|
||||
status: 'auth_invalid',
|
||||
quota_snapshot: {
|
||||
code: 'unknown',
|
||||
exhausted: false,
|
||||
observed_at: 456,
|
||||
},
|
||||
},
|
||||
{
|
||||
key_id: 'unchanged',
|
||||
key_name: 'unchanged',
|
||||
status: 'error',
|
||||
},
|
||||
]
|
||||
|
||||
const merged = mergePoolKeyQuotaSnapshots(keys, results)
|
||||
|
||||
expect(merged[0].status_snapshot?.quota.code).toBe('exhausted')
|
||||
expect(merged[0].quota_updated_at).toBe(123)
|
||||
expect(merged[1].status_snapshot?.quota.code).toBe('unknown')
|
||||
expect(merged[1].quota_updated_at).toBe(456)
|
||||
expect(merged[2]).toBe(keys[2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||
|
||||
export function resetCodexCycleUsageWindows(
|
||||
windows: QuotaWindowSnapshot[],
|
||||
resetAt: number,
|
||||
): QuotaWindowSnapshot[] {
|
||||
return windows.map((window) => {
|
||||
const code = String(window.code || '').trim()
|
||||
const scope = String(window.scope || 'account').trim().toLowerCase()
|
||||
const shouldReset = Boolean(code)
|
||||
&& scope === 'account'
|
||||
&& !code.toLowerCase().startsWith('spark_')
|
||||
&& window.window_minutes !== 0
|
||||
|
||||
if (!shouldReset) return window
|
||||
|
||||
return {
|
||||
...window,
|
||||
usage_reset_at: Number.isFinite(resetAt) ? resetAt : window.usage_reset_at,
|
||||
usage: {
|
||||
request_count: 0,
|
||||
total_tokens: 0,
|
||||
total_cost_usd: '0.00000000',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { PoolKeyDetail } from '@/api/endpoints/pool'
|
||||
import type { RefreshQuotaResult } from '@/api/endpoints/keys'
|
||||
|
||||
export function mergePoolKeyQuotaSnapshots(
|
||||
keys: PoolKeyDetail[],
|
||||
results: RefreshQuotaResult['results'],
|
||||
): PoolKeyDetail[] {
|
||||
const quotaByKeyId = new Map<string, NonNullable<RefreshQuotaResult['results'][number]['quota_snapshot']>>()
|
||||
for (const result of results) {
|
||||
if (result.quota_snapshot) {
|
||||
quotaByKeyId.set(result.key_id, result.quota_snapshot)
|
||||
}
|
||||
}
|
||||
if (quotaByKeyId.size === 0) return keys
|
||||
|
||||
return keys.map((key) => {
|
||||
const quotaSnapshot = quotaByKeyId.get(key.key_id)
|
||||
if (!quotaSnapshot) return key
|
||||
return {
|
||||
...key,
|
||||
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
|
||||
status_snapshot: {
|
||||
...(key.status_snapshot ?? {}),
|
||||
quota: quotaSnapshot,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1091,6 +1091,14 @@ let mappingPreviewLoadRequestId = 0
|
||||
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
|
||||
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4
|
||||
|
||||
function applyProviderSnapshot(updated: ProviderWithEndpointsSummary): void {
|
||||
if (provider.value?.id === updated.id) {
|
||||
Object.assign(provider.value, updated)
|
||||
return
|
||||
}
|
||||
provider.value = updated
|
||||
}
|
||||
|
||||
function getProviderKeysPageSize(providerType?: string | null): number {
|
||||
return (providerType || '').trim().toLowerCase() === 'custom'
|
||||
? CUSTOM_PROVIDER_KEYS_PAGE_SIZE
|
||||
@@ -1386,7 +1394,7 @@ async function toggleFormatConversion() {
|
||||
const newValue = !provider.value.enable_format_conversion
|
||||
try {
|
||||
const updated = await updateProvider(provider.value.id, { enable_format_conversion: newValue })
|
||||
provider.value = updated
|
||||
applyProviderSnapshot(updated)
|
||||
showSuccess(legacyT(newValue ? '已启用格式转换' : '已禁用格式转换'))
|
||||
emit('refresh')
|
||||
} catch {
|
||||
@@ -1408,7 +1416,7 @@ async function setProviderProxy(nodeId: string) {
|
||||
const updated = await updateProvider(provider.value.id, {
|
||||
proxy: { node_id: nodeId, enabled: true },
|
||||
})
|
||||
provider.value = updated
|
||||
applyProviderSnapshot(updated)
|
||||
providerProxyPopoverOpen.value = false
|
||||
showSuccess(legacyT('代理节点已设置'))
|
||||
emit('refresh')
|
||||
@@ -1424,7 +1432,7 @@ async function clearProviderProxy() {
|
||||
savingProviderProxy.value = true
|
||||
try {
|
||||
const updated = await updateProvider(provider.value.id, { proxy: null })
|
||||
provider.value = updated
|
||||
applyProviderSnapshot(updated)
|
||||
providerProxyPopoverOpen.value = false
|
||||
showSuccess(legacyT('已清除提供商代理'))
|
||||
emit('refresh')
|
||||
@@ -1447,7 +1455,7 @@ function handleEditEndpoint(_endpoint: ProviderEndpoint) {
|
||||
}
|
||||
|
||||
async function handleEndpointChanged() {
|
||||
await Promise.all([loadProvider(), loadEndpoints()])
|
||||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
@@ -1562,7 +1570,7 @@ async function confirmDeleteKey() {
|
||||
await deleteEndpointKey(keyId)
|
||||
showSuccess(legacyT('密钥已删除'))
|
||||
// 刷新端点列表及模型数据(删除 Key 触发自动解除模型关联)
|
||||
await loadEndpoints()
|
||||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||
emit('refresh')
|
||||
} catch (err: unknown) {
|
||||
showError(localizedApiError(err, '删除密钥失败'), legacyT('错误'))
|
||||
@@ -1573,7 +1581,7 @@ async function handleRecoverKey(key: EndpointAPIKey) {
|
||||
try {
|
||||
const result = await recoverKeyHealth(key.id)
|
||||
showSuccess(legacyT(result.message || 'Key已完全恢复'))
|
||||
await loadEndpoints()
|
||||
await Promise.all([loadProvider(), loadEndpoints()])
|
||||
emit('refresh')
|
||||
} catch (err: unknown) {
|
||||
showError(localizedApiError(err, 'Key恢复失败'), legacyT('错误'))
|
||||
@@ -1625,11 +1633,15 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
} else {
|
||||
showSuccess(legacyT(feedback.message))
|
||||
}
|
||||
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
||||
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
|
||||
void autoRefreshQuotaInBackground()
|
||||
emit('refresh')
|
||||
// Token 刷新可能激活账号并更新配额,完成后再同步一次父列表。
|
||||
void autoRefreshQuotaInBackground().then((changed) => {
|
||||
if (changed) emit('refresh')
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误'))
|
||||
await Promise.all([loadProvider(), loadEndpoints()])
|
||||
emit('refresh')
|
||||
} finally {
|
||||
refreshingOAuthKeyId.value = null
|
||||
}
|
||||
@@ -1692,9 +1704,12 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadEndpoints()
|
||||
await Promise.all([loadProvider(), loadEndpoints()])
|
||||
emit('refresh')
|
||||
} catch (err: unknown) {
|
||||
showError(localizedApiError(err, '清除失败'), legacyT('错误'))
|
||||
await Promise.all([loadProvider(), loadEndpoints()])
|
||||
emit('refresh')
|
||||
} finally {
|
||||
clearingOAuthInvalidKeyId.value = null
|
||||
}
|
||||
@@ -1755,8 +1770,11 @@ async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
|
||||
if (result.refresh_status === 'failed') {
|
||||
showWarning(legacyT(result.refresh_error || '重置请求已处理,但最新配额刷新失败'))
|
||||
}
|
||||
emit('refresh')
|
||||
} catch (err: unknown) {
|
||||
showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误'))
|
||||
await Promise.all([loadProvider(), loadEndpoints()])
|
||||
emit('refresh')
|
||||
} finally {
|
||||
consumingCodexResetCreditKeyId.value = null
|
||||
}
|
||||
@@ -2707,13 +2725,13 @@ function applyQuotaResults(
|
||||
}
|
||||
|
||||
// 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
|
||||
async function autoRefreshQuotaInBackground() {
|
||||
async function autoRefreshQuotaInBackground(): Promise<boolean> {
|
||||
const providerId = props.providerId
|
||||
if (!providerId) return
|
||||
if (refreshingQuota.value) return
|
||||
if (!providerId) return false
|
||||
if (refreshingQuota.value) return false
|
||||
|
||||
const providerType = provider.value?.provider_type
|
||||
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
|
||||
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return false
|
||||
|
||||
// 检查是否需要刷新
|
||||
let shouldRefresh = false
|
||||
@@ -2732,7 +2750,7 @@ async function autoRefreshQuotaInBackground() {
|
||||
} else if (providerType === 'chatgpt_web') {
|
||||
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
|
||||
}
|
||||
if (!shouldRefresh) return
|
||||
if (!shouldRefresh) return false
|
||||
|
||||
let hadCachedQuota = false
|
||||
if (providerType === 'codex') {
|
||||
@@ -2758,10 +2776,12 @@ async function autoRefreshQuotaInBackground() {
|
||||
if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') {
|
||||
showError(legacyT('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)'), legacyT('提示'))
|
||||
}
|
||||
return applied > 0
|
||||
} catch (err: unknown) {
|
||||
if (!hadCachedQuota && providerType === 'antigravity') {
|
||||
showError(localizedApiError(err, '后台刷新配额失败'), legacyT('错误'))
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
refreshingQuota.value = false
|
||||
}
|
||||
@@ -2792,10 +2812,12 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
|
||||
}
|
||||
|
||||
async function handleKeyChanged() {
|
||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
||||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||
emit('refresh')
|
||||
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
|
||||
void autoRefreshQuotaInBackground()
|
||||
void autoRefreshQuotaInBackground().then((changed) => {
|
||||
if (changed) emit('refresh')
|
||||
})
|
||||
}
|
||||
|
||||
// 切换密钥启用状态
|
||||
@@ -2805,8 +2827,10 @@ async function toggleKeyActive(key: EndpointAPIKey) {
|
||||
togglingKeyId.value = key.id
|
||||
try {
|
||||
const newStatus = !key.is_active
|
||||
await updateProviderKey(key.id, { is_active: newStatus })
|
||||
const updated = await updateProviderKey(key.id, { is_active: newStatus })
|
||||
Object.assign(key, updated)
|
||||
key.is_active = newStatus
|
||||
await Promise.all([loadProvider(), loadEndpoints()])
|
||||
showSuccess(legacyT(newStatus ? '密钥已启用' : '密钥已停用'))
|
||||
emit('refresh')
|
||||
} catch (err: unknown) {
|
||||
@@ -2885,20 +2909,20 @@ function handleBatchAssignDialogOpenUpdate(value: boolean) {
|
||||
|
||||
// 处理批量关联完成
|
||||
async function handleBatchAssignChanged() {
|
||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
||||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 处理模型映射变更
|
||||
async function handleModelMappingChanged() {
|
||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
||||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 处理模型保存完成
|
||||
async function handleModelSaved() {
|
||||
editingModel.value = null
|
||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
||||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
@@ -3738,7 +3762,7 @@ async function loadProvider() {
|
||||
void loadSystemFormatConversionConfig()
|
||||
const providerData = await getProvider(props.providerId)
|
||||
if (requestId !== providerLoadRequestId) return
|
||||
provider.value = providerData
|
||||
applyProviderSnapshot(providerData)
|
||||
keyPageSize.value = getProviderKeysPageSize(providerData.provider_type)
|
||||
|
||||
if (!provider.value) {
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:title="legacyT(provider.is_active ? '点击停用' : '点击启用')"
|
||||
:aria-label="legacyT(provider.is_active ? '点击停用' : '点击启用')"
|
||||
data-testid="provider-toggle-active"
|
||||
@click="$emit('toggleStatus', provider)"
|
||||
>
|
||||
<Power class="w-4 h-4" />
|
||||
|
||||
@@ -126,6 +126,8 @@
|
||||
class="h-7 w-7"
|
||||
:disabled="toggling"
|
||||
:title="legacyT(apiKey.is_active ? '点击停用' : '点击启用')"
|
||||
:aria-label="legacyT(apiKey.is_active ? '点击停用' : '点击启用')"
|
||||
data-testid="provider-key-toggle-active"
|
||||
@click="$emit('toggleActive')"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
|
||||
@@ -128,6 +128,7 @@ describe('ProviderKeyActionCluster', () => {
|
||||
expect(root.querySelector('button[title="Recover key"]')).toBeTruthy()
|
||||
expect(root.querySelector('button[title="代理: Tokyo"]')).toBeTruthy()
|
||||
expect(root.querySelector('button[title="配额详情"]')).toBeTruthy()
|
||||
expect(root.querySelector('[data-testid="provider-key-toggle-active"]')?.getAttribute('aria-label')).toBe('点击停用')
|
||||
|
||||
unmount()
|
||||
})
|
||||
@@ -179,4 +180,22 @@ describe('ProviderKeyActionCluster', () => {
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('offers an enable action for an inactive account', () => {
|
||||
const onToggleActive = vi.fn()
|
||||
const { root, unmount } = mount({
|
||||
apiKey: createProviderKey({ is_active: false }),
|
||||
onToggleActive,
|
||||
})
|
||||
|
||||
const enableButton = root.querySelector('[data-testid="provider-key-toggle-active"]') as HTMLButtonElement | null
|
||||
expect(enableButton).toBeTruthy()
|
||||
expect(enableButton?.getAttribute('title')).toBe('点击启用')
|
||||
expect(enableButton?.getAttribute('aria-label')).toBe('点击启用')
|
||||
|
||||
enableButton?.click()
|
||||
expect(onToggleActive).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user