mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
Merge origin/main into main
Integrate upstream updates while preserving the local analytics dashboards and schema-only migration changes. Combine user account analysis with upstream user/group usage statistics in separate tabs, retain all migration versions, and keep the deleted audit document removed. Validation: gateway all-target cargo check, frontend type check and 57 focused tests, 48 migration tests, schema composition checks, and diff whitespace checks.
This commit is contained in:
@@ -82,4 +82,35 @@ describe('adminApi analytics cache options', () => {
|
||||
})
|
||||
expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params })
|
||||
})
|
||||
|
||||
it('requests the user group leaderboard with scoped cache parameters', async () => {
|
||||
const groupParams = {
|
||||
...params,
|
||||
metric: 'cost' as const,
|
||||
offset: 10,
|
||||
limit: 10,
|
||||
include_inactive: true,
|
||||
}
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: { items: [], total: 0, metric: 'cost', attribution: 'current_membership' },
|
||||
})
|
||||
|
||||
await expect(adminApi.getLeaderboardUserGroups(groupParams)).resolves.toMatchObject({
|
||||
attribution: 'current_membership',
|
||||
})
|
||||
|
||||
expect(buildCacheKeyMock).toHaveBeenCalledWith(
|
||||
'admin:stats:leaderboard:user-groups',
|
||||
groupParams
|
||||
)
|
||||
expect(cachedRequestMock).toHaveBeenCalledWith(
|
||||
'admin:stats:leaderboard:user-groups',
|
||||
expect.any(Function),
|
||||
20 * 1000
|
||||
)
|
||||
expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/user-groups', {
|
||||
params: groupParams,
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock, patchMock } = vi.hoisted(() => ({ getMock: vi.fn(), patchMock: vi.fn() }))
|
||||
|
||||
vi.mock('@/api/client', () => ({ default: { get: getMock, patch: patchMock } }))
|
||||
|
||||
import { getPoolOverview, listPoolKeys, listPoolScores } from '@/api/endpoints/pool'
|
||||
import { getProvider, updateProvider } from '@/api/endpoints/providers'
|
||||
import { cache } from '@/utils/cache'
|
||||
|
||||
const options = { cacheTtlMs: 30_000 }
|
||||
const provider = { id: 'codex', pool_advanced: { reserve_minimum_quota: false } }
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cache.clear()
|
||||
getMock.mockReset()
|
||||
patchMock.mockReset()
|
||||
patchMock.mockResolvedValue({ data: provider })
|
||||
})
|
||||
|
||||
describe('provider pool settings cache invalidation', () => {
|
||||
it('refreshes every cached key page, score page and overview after changing pool settings', async () => {
|
||||
getMock.mockResolvedValue({ data: { version: 'old' } })
|
||||
await getPoolOverview(options)
|
||||
await listPoolKeys('codex', {}, options)
|
||||
await listPoolKeys('codex', { page: 2, status: 'quota_exhausted' }, options)
|
||||
await listPoolScores('codex', {}, options)
|
||||
await listPoolKeys('codex-other', {}, options)
|
||||
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
|
||||
getMock.mockResolvedValue({ data: { version: 'new' } })
|
||||
|
||||
await expect(getPoolOverview(options)).resolves.toEqual({ version: 'new' })
|
||||
await expect(listPoolKeys('codex', {}, options)).resolves.toEqual({ version: 'new' })
|
||||
await expect(listPoolKeys('codex', { page: 2, status: 'quota_exhausted' }, options))
|
||||
.resolves.toEqual({ version: 'new' })
|
||||
await expect(listPoolScores('codex', {}, options)).resolves.toEqual({ version: 'new' })
|
||||
await expect(listPoolKeys('codex-other', {}, options)).resolves.toEqual({ version: 'old' })
|
||||
})
|
||||
|
||||
it('does not reuse or cache a key request started before the settings were saved', async () => {
|
||||
const oldResponse = deferred<{ data: { version: string } }>()
|
||||
const newResponse = deferred<{ data: { version: string } }>()
|
||||
getMock.mockReturnValueOnce(oldResponse.promise).mockReturnValueOnce(newResponse.promise)
|
||||
const oldRequest = listPoolKeys('codex', { page: 2 }, options)
|
||||
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
|
||||
const newRequest = listPoolKeys('codex', { page: 2 }, options)
|
||||
expect(getMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
oldResponse.resolve({ data: { version: 'old' } })
|
||||
await oldRequest
|
||||
const deduped = listPoolKeys('codex', { page: 2 }, options)
|
||||
expect(getMock).toHaveBeenCalledTimes(2)
|
||||
newResponse.resolve({ data: { version: 'new' } })
|
||||
await expect(newRequest).resolves.toEqual({ version: 'new' })
|
||||
await expect(deduped).resolves.toEqual({ version: 'new' })
|
||||
await expect(listPoolKeys('codex', { page: 2 }, options)).resolves.toEqual({ version: 'new' })
|
||||
})
|
||||
|
||||
it('does not reuse a provider detail request started before a successful save', async () => {
|
||||
const oldResponse = deferred<{ data: typeof provider }>()
|
||||
getMock.mockReturnValueOnce(oldResponse.promise).mockResolvedValueOnce({ data: provider })
|
||||
const oldRequest = getProvider('codex')
|
||||
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
|
||||
const newRequest = getProvider('codex')
|
||||
expect(getMock).toHaveBeenCalledTimes(2)
|
||||
oldResponse.resolve({ data: { ...provider, pool_advanced: { reserve_minimum_quota: true } } })
|
||||
await oldRequest
|
||||
await expect(newRequest).resolves.toMatchObject(provider)
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ vi.mock('@/utils/cache', () => ({
|
||||
cachedRequest: cachedRequestMock,
|
||||
}))
|
||||
|
||||
import { usersApi } from '@/api/users'
|
||||
import { buildUserBatchBalanceAdjustmentPayload, usersApi } from '@/api/users'
|
||||
|
||||
describe('usersApi admin list query', () => {
|
||||
beforeEach(() => {
|
||||
@@ -104,3 +104,25 @@ describe('usersApi admin list query', () => {
|
||||
expect(getMock).toHaveBeenCalledWith('/api/admin/users/target-user/api-keys')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user batch wallet balance payload', () => {
|
||||
it('builds an addition payload from a valid positive amount', () => {
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('add', '12.5')).toEqual({
|
||||
operation: 'add',
|
||||
amount: 12.5,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a deduction payload from a valid positive amount', () => {
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('deduct', 4)).toEqual({
|
||||
operation: 'deduct',
|
||||
amount: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects zero, blank, and non-finite amounts', () => {
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('add', '0')).toBeNull()
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '')).toBeNull()
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '1e999')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -709,6 +709,8 @@ export interface LeaderboardItem {
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
member_count?: number
|
||||
active_member_count?: number
|
||||
}
|
||||
|
||||
export interface LeaderboardResponse {
|
||||
@@ -717,6 +719,7 @@ export interface LeaderboardResponse {
|
||||
metric: string
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
attribution?: 'current_membership'
|
||||
}
|
||||
|
||||
export interface CostForecastResponse {
|
||||
@@ -1298,6 +1301,7 @@ export const adminApi = {
|
||||
model?: string
|
||||
include_inactive?: boolean
|
||||
exclude_admin?: boolean
|
||||
user_group_id?: string
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
|
||||
return cachedRequest(
|
||||
@@ -1312,6 +1316,35 @@ export const adminApi = {
|
||||
)
|
||||
},
|
||||
|
||||
async getLeaderboardUserGroups(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
metric?: 'requests' | 'tokens' | 'cost'
|
||||
order?: 'asc' | 'desc'
|
||||
limit?: number
|
||||
offset?: number
|
||||
provider_name?: string
|
||||
model?: string
|
||||
include_inactive?: boolean
|
||||
exclude_admin?: boolean
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:user-groups', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<LeaderboardResponse>(
|
||||
'/api/admin/stats/leaderboard/user-groups',
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getLeaderboardApiKeys(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
@@ -1595,6 +1628,7 @@ export const adminApi = {
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
user_id?: string
|
||||
user_group_id?: string
|
||||
model?: string
|
||||
provider_name?: string
|
||||
},
|
||||
|
||||
@@ -219,6 +219,7 @@ export interface RequestDetail {
|
||||
has_format_conversion?: boolean | null
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名
|
||||
response_model?: string | null // 上游响应体实际返回的模型名
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
|
||||
@@ -376,7 +376,7 @@ function jsonValueContainsAgentIdentity(value: unknown): boolean {
|
||||
export interface DeviceAuthorizeRequest {
|
||||
start_url?: string
|
||||
region?: string
|
||||
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser'
|
||||
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser' | 'device'
|
||||
login_option?: 'google' | 'github' | 'default'
|
||||
redirect_uri?: string
|
||||
proxy_node_id?: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from '../client'
|
||||
import { buildCacheKey, cachedRequest, dedupedRequest } from '@/utils/cache'
|
||||
import { buildCacheKey, cache, cachedRequest, dedupedRequest } from '@/utils/cache'
|
||||
import type {
|
||||
ClaudeCodeAdvancedConfig,
|
||||
FailoverRulesConfig,
|
||||
@@ -142,6 +142,15 @@ export async function updateProvider(
|
||||
requestOptions?: ProviderRequestOptions,
|
||||
): Promise<ProviderWithEndpointsSummary> {
|
||||
const response = await client.patch<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}`, data, requestOptions)
|
||||
cache.delete(`providers:detail:${providerId}`)
|
||||
if ('pool_advanced' in data) {
|
||||
cache.delete('pool:overview')
|
||||
for (const kind of ['keys', 'scores']) {
|
||||
const prefix = `pool:${kind}:${providerId}`
|
||||
cache.delete(prefix)
|
||||
cache.deleteByPrefix(`${prefix}:`)
|
||||
}
|
||||
}
|
||||
return normalizeProviderSummary(response.data)
|
||||
}
|
||||
|
||||
|
||||
@@ -462,6 +462,23 @@ export interface GrokUpstreamMetadata {
|
||||
account_user_id?: string | null
|
||||
}
|
||||
|
||||
export interface XaiUpstreamMetadata {
|
||||
updated_at?: number
|
||||
subscription_title?: string
|
||||
usage_percentage?: number
|
||||
remaining_percentage?: number
|
||||
usage_label?: string
|
||||
usage_limit?: number
|
||||
current_usage?: number
|
||||
remaining?: number
|
||||
next_reset_at?: number
|
||||
prepaid_balance?: number
|
||||
on_demand_cap?: number
|
||||
on_demand_used?: number
|
||||
on_demand_remaining?: number
|
||||
period_type?: string
|
||||
}
|
||||
|
||||
export interface GeminiCliTierMetadata {
|
||||
id?: string | null
|
||||
tierType?: string | null
|
||||
@@ -512,14 +529,29 @@ export interface GeminiCliUpstreamMetadata {
|
||||
quota_by_model?: Record<string, GeminiCliModelQuota> | null
|
||||
}
|
||||
|
||||
export interface ClaudeCodeUpstreamMetadata {
|
||||
updated_at?: number
|
||||
five_hour_used_percent?: number
|
||||
five_hour_reset_at?: number
|
||||
seven_day_used_percent?: number
|
||||
seven_day_reset_at?: number
|
||||
seven_day_sonnet_used_percent?: number
|
||||
seven_day_sonnet_reset_at?: number
|
||||
seven_day_fable_used_percent?: number
|
||||
seven_day_fable_reset_at?: number
|
||||
reset_credits?: QuotaResetCreditsSnapshot
|
||||
}
|
||||
|
||||
export interface UpstreamMetadata {
|
||||
codex?: CodexUpstreamMetadata
|
||||
claude_code?: ClaudeCodeUpstreamMetadata
|
||||
antigravity?: AntigravityUpstreamMetadata
|
||||
kiro?: KiroUpstreamMetadata
|
||||
windsurf?: WindsurfUpstreamMetadata
|
||||
chatgpt_web?: ChatGPTWebUpstreamMetadata
|
||||
grok?: GrokUpstreamMetadata
|
||||
gemini_cli?: GeminiCliUpstreamMetadata
|
||||
xai?: XaiUpstreamMetadata
|
||||
}
|
||||
|
||||
// 按格式的健康度数据
|
||||
@@ -758,7 +790,7 @@ export interface HealthRelatedMonitorResponse {
|
||||
related_providers: HealthRelatedMonitor[]
|
||||
}
|
||||
|
||||
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'windsurf' | 'vertex_ai'
|
||||
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'xai' | 'windsurf' | 'vertex_ai'
|
||||
|
||||
export interface ClaudeCodeAdvancedConfig {
|
||||
// 会话数量控制:null/undefined 表示不限制
|
||||
@@ -804,6 +836,8 @@ export interface PoolAdvancedConfig {
|
||||
sticky_session_ttl_seconds?: number | null
|
||||
load_threshold_percent?: number | null
|
||||
skip_exhausted_accounts?: boolean | null
|
||||
// Codex only: treat remaining quota <= 1% as exhausted (default false).
|
||||
reserve_minimum_quota?: boolean
|
||||
// 旧字段(兼容读取)
|
||||
lru_enabled?: boolean
|
||||
scheduling_mode?: 'lru' | 'multi_score' | null
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface UsageRecordDetail {
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
response_model?: string | null
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
@@ -392,6 +393,7 @@ export const meApi = {
|
||||
has_format_conversion?: boolean | null
|
||||
has_fallback?: boolean | null
|
||||
target_model?: string | null
|
||||
response_model?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
@@ -438,6 +440,7 @@ export const meApi = {
|
||||
has_format_conversion?: boolean | null
|
||||
has_fallback?: boolean | null
|
||||
target_model?: string | null
|
||||
response_model?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface UsageRecord {
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
response_model?: string | null
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
@@ -136,6 +137,7 @@ export interface UsageFilters {
|
||||
from?: string
|
||||
to?: string
|
||||
user_id?: string // UUID
|
||||
user_group_id?: string // UUID
|
||||
provider_id?: string // UUID
|
||||
model?: string
|
||||
search?: string
|
||||
@@ -641,6 +643,7 @@ export const usageApi = {
|
||||
has_format_conversion?: boolean | null
|
||||
has_fallback?: boolean | null
|
||||
target_model?: string | null
|
||||
response_model?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
@@ -712,6 +715,7 @@ export const usageApi = {
|
||||
has_format_conversion?: boolean | null
|
||||
has_fallback?: boolean | null
|
||||
target_model?: string | null
|
||||
response_model?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface UsageRecord {
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
|
||||
response_model?: string | null // 上游响应体实际返回的模型名
|
||||
request_type?: string | null // 由请求语义识别出的操作类型
|
||||
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
|
||||
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
|
||||
@@ -65,5 +66,13 @@ export interface UsageRecord {
|
||||
response_time_updated_at?: string | null
|
||||
has_fallback?: boolean
|
||||
has_retry?: boolean
|
||||
/**
|
||||
* 是否存在被调度跳过的候选(候选在调度阶段即被判定不可用,从未向上游发起请求)。
|
||||
* 与 has_fallback 的区别:has_fallback 代表"更靠前的候选真的失败了",
|
||||
* 本字段代表"更靠前的候选压根没被发出去",用于解释"无报错却换了提供商"。
|
||||
*/
|
||||
has_skipped_candidate?: boolean
|
||||
/** 被跳过候选的原因列表(后端已按候选顺序去重) */
|
||||
skipped_candidate_reasons?: string[]
|
||||
image_progress?: ImageProgress | null
|
||||
}
|
||||
|
||||
@@ -120,9 +120,28 @@ export interface UserBatchRolePayload {
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role'
|
||||
export type UserBatchBalanceOperation = 'add' | 'deduct'
|
||||
|
||||
export type UserBatchActionPayload = UserBatchAccessControlPayload | UserBatchRolePayload
|
||||
export interface UserBatchBalanceAdjustmentPayload {
|
||||
operation: UserBatchBalanceOperation
|
||||
amount: number
|
||||
}
|
||||
|
||||
export function buildUserBatchBalanceAdjustmentPayload(
|
||||
operation: UserBatchBalanceOperation,
|
||||
amountInput: string | number,
|
||||
): UserBatchBalanceAdjustmentPayload | null {
|
||||
const amount = typeof amountInput === 'number' ? amountInput : Number(amountInput.trim())
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null
|
||||
return { operation, amount }
|
||||
}
|
||||
|
||||
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role' | 'adjust_wallet_balance'
|
||||
|
||||
export type UserBatchActionPayload =
|
||||
| UserBatchAccessControlPayload
|
||||
| UserBatchRolePayload
|
||||
| UserBatchBalanceAdjustmentPayload
|
||||
|
||||
export interface UserBatchToggleActionRequest {
|
||||
selection: UserBatchSelection
|
||||
@@ -142,10 +161,18 @@ export interface UserBatchRoleActionRequest {
|
||||
payload: UserBatchRolePayload
|
||||
}
|
||||
|
||||
export interface UserBatchBalanceActionRequest {
|
||||
selection: UserBatchSelection
|
||||
action: 'adjust_wallet_balance'
|
||||
payload: UserBatchBalanceAdjustmentPayload
|
||||
idempotency_key: string
|
||||
}
|
||||
|
||||
export type UserBatchActionRequest =
|
||||
| UserBatchToggleActionRequest
|
||||
| UserBatchAccessControlActionRequest
|
||||
| UserBatchRoleActionRequest
|
||||
| UserBatchBalanceActionRequest
|
||||
|
||||
export interface UserBatchActionFailure {
|
||||
user_id: string
|
||||
@@ -157,6 +184,10 @@ export interface UserBatchActionResponse {
|
||||
success: number
|
||||
failed: number
|
||||
failures: UserBatchActionFailure[]
|
||||
interrupted?: boolean
|
||||
completed_user_ids?: string[]
|
||||
uncertain_user_ids?: string[]
|
||||
unprocessed_user_ids?: string[]
|
||||
warnings?: UserBatchSelectionWarning[]
|
||||
action?: string
|
||||
modified_fields?: string[]
|
||||
|
||||
Reference in New Issue
Block a user