mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge remote-tracking branch 'origin/pr-491'
This commit is contained in:
@@ -502,7 +502,9 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
|
||||
.summarize_usage_audits(&UsageAuditSummaryQuery {
|
||||
created_from_unix_secs,
|
||||
created_until_unix_secs,
|
||||
..Default::default()
|
||||
user_id: query_param_value(query, "user_id"),
|
||||
provider_name: query_param_value(query, "provider"),
|
||||
model: query_param_value(query, "model"),
|
||||
})
|
||||
.await?;
|
||||
return Ok(Some(build_admin_usage_summary_stats_response_from_summary(
|
||||
|
||||
@@ -387,6 +387,71 @@ async fn gateway_handles_admin_usage_stats_locally_with_trusted_admin_principal(
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_filters_admin_usage_stats_by_query_fields_locally() {
|
||||
let (upstream_url, upstream_hits, upstream_handle) =
|
||||
start_usage_upstream("/api/admin/usage/stats").await;
|
||||
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||
sample_usage_row(
|
||||
"usage-1",
|
||||
"req-1",
|
||||
Some("user-1"),
|
||||
Some("key-1"),
|
||||
Some("primary"),
|
||||
"OpenAI",
|
||||
"gpt-5",
|
||||
"completed",
|
||||
120,
|
||||
30,
|
||||
0.3,
|
||||
0.36,
|
||||
DAY_1_UNIX_SECS,
|
||||
),
|
||||
sample_usage_row(
|
||||
"usage-2",
|
||||
"req-2",
|
||||
Some("user-2"),
|
||||
Some("key-2"),
|
||||
Some("secondary"),
|
||||
"Anthropic",
|
||||
"claude-3-7",
|
||||
"failed",
|
||||
40,
|
||||
10,
|
||||
0.1,
|
||||
0.12,
|
||||
DAY_2_UNIX_SECS,
|
||||
),
|
||||
]));
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(
|
||||
usage_repository,
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = admin_request(reqwest::Client::new().get(format!(
|
||||
"{gateway_url}/api/admin/usage/stats?start_date=2024-03-21&end_date=2024-03-22&tz_offset_minutes=0&user_id=user-2&provider=Anthropic&model=claude-3-7"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["total_requests"], 1);
|
||||
assert_eq!(payload["total_tokens"], 70);
|
||||
assert_eq!(payload["error_count"], 1);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_defaults_admin_usage_stats_to_bounded_recent_window_when_query_missing() {
|
||||
let (upstream_url, upstream_hits, upstream_handle) =
|
||||
|
||||
117
frontend/src/api/__tests__/usage-contract.spec.ts
Normal file
117
frontend/src/api/__tests__/usage-contract.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock, cachedRequestMock, dedupedRequestMock, buildCacheKeyMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
cachedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
|
||||
dedupedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
|
||||
buildCacheKeyMock: vi.fn(() => 'cache-key'),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
get: getMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/cache', () => ({
|
||||
cachedRequest: cachedRequestMock,
|
||||
dedupedRequest: dedupedRequestMock,
|
||||
buildCacheKey: buildCacheKeyMock,
|
||||
}))
|
||||
|
||||
import { usageApi } from '@/api/usage'
|
||||
|
||||
describe('usageApi contract alignment', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
cachedRequestMock.mockClear()
|
||||
dedupedRequestMock.mockClear()
|
||||
buildCacheKeyMock.mockClear()
|
||||
})
|
||||
|
||||
it('loads current-user usage records from the Rust usage endpoint and normalizes pagination', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
records: [{ id: 'record-1' }],
|
||||
pagination: {
|
||||
total: 42,
|
||||
limit: 10,
|
||||
offset: 10,
|
||||
has_more: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await usageApi.getUsageRecords({
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
start_date: '2026-05-01',
|
||||
end_date: '2026-05-16',
|
||||
})
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/api/users/me/usage', {
|
||||
params: {
|
||||
limit: 10,
|
||||
offset: 10,
|
||||
start_date: '2026-05-01',
|
||||
end_date: '2026-05-16',
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({
|
||||
records: [{ id: 'record-1' }],
|
||||
total: 42,
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads admin usage for a specific user from admin usage endpoints', async () => {
|
||||
getMock
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
total_requests: 7,
|
||||
total_tokens: 99,
|
||||
total_cost: 12.34,
|
||||
avg_response_time: 456,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
records: [{ id: 'record-2' }],
|
||||
total: 1,
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await usageApi.getUserUsage('user-123', {
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
model: 'gpt-5.5',
|
||||
})
|
||||
|
||||
expect(getMock).toHaveBeenNthCalledWith(1, '/api/admin/usage/stats', {
|
||||
params: {
|
||||
user_id: 'user-123',
|
||||
model: 'gpt-5.5',
|
||||
},
|
||||
})
|
||||
expect(getMock).toHaveBeenNthCalledWith(2, '/api/admin/usage/records', {
|
||||
params: {
|
||||
user_id: 'user-123',
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
model: 'gpt-5.5',
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({
|
||||
records: [{ id: 'record-2' }],
|
||||
stats: {
|
||||
total_requests: 7,
|
||||
total_tokens: 99,
|
||||
total_cost: 12.34,
|
||||
avg_response_time: 456,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -100,6 +100,7 @@ export interface UsageFilters {
|
||||
user_id?: string // UUID
|
||||
provider_id?: string // UUID
|
||||
model?: string
|
||||
search?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
@@ -110,6 +111,172 @@ export interface UsageFilters {
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
type UsageListResponse = {
|
||||
records?: unknown
|
||||
pagination?: {
|
||||
total?: unknown
|
||||
limit?: unknown
|
||||
offset?: unknown
|
||||
}
|
||||
total?: unknown
|
||||
limit?: unknown
|
||||
offset?: unknown
|
||||
}
|
||||
|
||||
function assertPositiveInteger(value: number, field: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${field} must be a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(value: number, field: string): number {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function assertNumber(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
throw new Error(`Usage response is missing numeric ${field}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function assertUsageRecords(value: unknown): UsageRecord[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Usage response is missing records array')
|
||||
}
|
||||
return value as UsageRecord[]
|
||||
}
|
||||
|
||||
function compactParams(params: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
)
|
||||
}
|
||||
|
||||
function offsetPaginationFromPage(filters?: Pick<UsageFilters, 'page' | 'page_size'>): {
|
||||
page: number
|
||||
pageSize: number | undefined
|
||||
offset: number | undefined
|
||||
} {
|
||||
const page = assertPositiveInteger(filters?.page ?? 1, 'page')
|
||||
if (filters?.page_size === undefined) {
|
||||
return { page, pageSize: undefined, offset: undefined }
|
||||
}
|
||||
|
||||
const pageSize = assertPositiveInteger(filters.page_size, 'page_size')
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
offset: assertNonNegativeInteger((page - 1) * pageSize, 'offset'),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUsageRecordPage(
|
||||
payload: UsageListResponse,
|
||||
requested: { page: number; pageSize?: number; offset?: number }
|
||||
): {
|
||||
records: UsageRecord[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
} {
|
||||
const records = assertUsageRecords(payload.records)
|
||||
const pagination = payload.pagination
|
||||
const total = assertNumber(pagination?.total ?? payload.total, 'pagination.total')
|
||||
const limit = assertPositiveInteger(
|
||||
assertNumber(pagination?.limit ?? payload.limit, 'pagination.limit'),
|
||||
'pagination.limit'
|
||||
)
|
||||
const offset = assertNonNegativeInteger(
|
||||
assertNumber(pagination?.offset ?? payload.offset, 'pagination.offset'),
|
||||
'pagination.offset'
|
||||
)
|
||||
const resolvedPage = requested.pageSize !== undefined
|
||||
? requested.page
|
||||
: Math.floor(offset / limit) + 1
|
||||
|
||||
return {
|
||||
records,
|
||||
total,
|
||||
page: resolvedPage,
|
||||
page_size: limit,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurrentUserUsageParams(filters?: UsageFilters): {
|
||||
params: Record<string, unknown>
|
||||
pagination: { page: number; pageSize?: number; offset?: number }
|
||||
} {
|
||||
if (filters?.user_id || filters?.provider_id || filters?.model || filters?.granularity) {
|
||||
throw new Error('getUsageRecords only supports current-user usage filters; use admin usage APIs for user/model/provider filters')
|
||||
}
|
||||
|
||||
const pagination = offsetPaginationFromPage(filters)
|
||||
return {
|
||||
pagination,
|
||||
params: compactParams({
|
||||
start_date: filters?.start_date,
|
||||
end_date: filters?.end_date,
|
||||
preset: filters?.preset,
|
||||
timezone: filters?.timezone,
|
||||
tz_offset_minutes: filters?.tz_offset_minutes,
|
||||
search: filters?.search,
|
||||
limit: pagination.pageSize,
|
||||
offset: pagination.offset,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildAdminUsageRecordParams(userId: string, filters?: UsageFilters): {
|
||||
params: Record<string, unknown>
|
||||
} {
|
||||
if (!userId.trim()) {
|
||||
throw new Error('getUserUsage requires a non-empty user id')
|
||||
}
|
||||
if (filters?.provider_id || filters?.granularity) {
|
||||
throw new Error('getUserUsage does not support provider_id or granularity filters')
|
||||
}
|
||||
|
||||
const pagination = offsetPaginationFromPage(filters)
|
||||
return {
|
||||
params: compactParams({
|
||||
user_id: userId,
|
||||
start_date: filters?.start_date,
|
||||
end_date: filters?.end_date,
|
||||
preset: filters?.preset,
|
||||
timezone: filters?.timezone,
|
||||
tz_offset_minutes: filters?.tz_offset_minutes,
|
||||
search: filters?.search,
|
||||
model: filters?.model,
|
||||
limit: pagination.pageSize,
|
||||
offset: pagination.offset,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildAdminUsageStatsParams(userId: string, filters?: UsageFilters): Record<string, unknown> {
|
||||
if (!userId.trim()) {
|
||||
throw new Error('getUserUsage requires a non-empty user id')
|
||||
}
|
||||
if (filters?.provider_id || filters?.granularity) {
|
||||
throw new Error('getUserUsage stats does not support provider_id or granularity filters')
|
||||
}
|
||||
|
||||
return compactParams({
|
||||
user_id: userId,
|
||||
start_date: filters?.start_date,
|
||||
end_date: filters?.end_date,
|
||||
preset: filters?.preset,
|
||||
timezone: filters?.timezone,
|
||||
tz_offset_minutes: filters?.tz_offset_minutes,
|
||||
model: filters?.model,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeActivityHeatmapResponse(payload: unknown): ActivityHeatmap {
|
||||
const today = new Date()
|
||||
const endDate = today.toISOString().slice(0, 10)
|
||||
@@ -184,8 +351,9 @@ export const usageApi = {
|
||||
page: number
|
||||
page_size: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/usage', { params: filters })
|
||||
return response.data
|
||||
const { params, pagination } = buildCurrentUserUsageParams(filters)
|
||||
const response = await apiClient.get<UsageListResponse>('/api/users/me/usage', { params })
|
||||
return normalizeUsageRecordPage(response.data, pagination)
|
||||
},
|
||||
|
||||
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
|
||||
@@ -244,16 +412,17 @@ export const usageApi = {
|
||||
records: UsageRecord[]
|
||||
stats: UsageStats
|
||||
}> {
|
||||
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
|
||||
return response.data
|
||||
},
|
||||
const statsParams = buildAdminUsageStatsParams(userId, filters)
|
||||
const { params: recordParams } = buildAdminUsageRecordParams(userId, filters)
|
||||
const [statsResponse, recordsResponse] = await Promise.all([
|
||||
apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
|
||||
apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
|
||||
])
|
||||
|
||||
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
|
||||
const response = await apiClient.get('/api/usage/export', {
|
||||
params: { ...filters, format },
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response.data
|
||||
return {
|
||||
records: assertUsageRecords(recordsResponse.data.records),
|
||||
stats: statsResponse.data,
|
||||
}
|
||||
},
|
||||
|
||||
async getAllUsageRecords(params?: {
|
||||
|
||||
Reference in New Issue
Block a user