forked from boos/BoosAPI
feat(tests): enhance modelTesterSession tests and add proxy logs tests
- Updated modelTesterSession tests to serialize assistant history as output_text and removed mime_type from input_file. - Introduced new tests for stats proxy logs routes, covering paginated log retrieval and detailed log views with parsed billing details. - Added server-driven tests for ProxyLogs component to validate API interactions and UI rendering based on server data.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('stats proxy logs routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-stats-proxy-logs-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./stats.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.statsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.proxyLogs).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('returns paginated proxy logs with server-side filters and summary metadata', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'proxy-site',
|
||||
url: 'https://proxy-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'proxy-user',
|
||||
accessToken: 'proxy-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const timestamps = [
|
||||
formatUtcSqlDateTime(new Date('2026-03-09T08:00:00.000Z')),
|
||||
formatUtcSqlDateTime(new Date('2026-03-09T08:01:00.000Z')),
|
||||
formatUtcSqlDateTime(new Date('2026-03-09T08:02:00.000Z')),
|
||||
formatUtcSqlDateTime(new Date('2026-03-09T08:03:00.000Z')),
|
||||
];
|
||||
|
||||
await db.insert(schema.proxyLogs).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
estimatedCost: 0.1,
|
||||
createdAt: timestamps[0],
|
||||
billingDetails: JSON.stringify({ id: 'success-gpt' }),
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o-mini',
|
||||
modelActual: 'gpt-4o-mini',
|
||||
status: 'failed',
|
||||
promptTokens: 8,
|
||||
completionTokens: 2,
|
||||
totalTokens: 10,
|
||||
estimatedCost: 0.2,
|
||||
createdAt: timestamps[1],
|
||||
billingDetails: JSON.stringify({ id: 'failed-gpt' }),
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4.1',
|
||||
modelActual: 'gpt-4.1',
|
||||
status: 'retried',
|
||||
promptTokens: 20,
|
||||
completionTokens: 4,
|
||||
totalTokens: 24,
|
||||
estimatedCost: 0.3,
|
||||
createdAt: timestamps[2],
|
||||
billingDetails: JSON.stringify({ id: 'retried-gpt' }),
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'claude-3-7-sonnet',
|
||||
modelActual: 'claude-3-7-sonnet',
|
||||
status: 'success',
|
||||
promptTokens: 40,
|
||||
completionTokens: 10,
|
||||
totalTokens: 50,
|
||||
estimatedCost: 0.4,
|
||||
createdAt: timestamps[3],
|
||||
billingDetails: JSON.stringify({ id: 'success-claude' }),
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/proxy-logs?limit=1&offset=1&status=failed&search=gpt',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary: {
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
totalCost: number;
|
||||
totalTokensAll: number;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.page).toBe(2);
|
||||
expect(body.pageSize).toBe(1);
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.items[0]?.modelRequested).toBe('gpt-4o-mini');
|
||||
expect(body.items[0]?.status).toBe('failed');
|
||||
expect(body.items[0]).not.toHaveProperty('billingDetails');
|
||||
expect(body.summary).toEqual({
|
||||
totalCount: 3,
|
||||
successCount: 1,
|
||||
failedCount: 2,
|
||||
totalCost: 0.6,
|
||||
totalTokensAll: 49,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a single proxy log detail with parsed billing details', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'detail-site',
|
||||
url: 'https://detail-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'detail-user',
|
||||
accessToken: 'detail-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const inserted = await db.insert(schema.proxyLogs).values({
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-5',
|
||||
modelActual: 'gpt-5',
|
||||
status: 'success',
|
||||
promptTokens: 100,
|
||||
completionTokens: 20,
|
||||
totalTokens: 120,
|
||||
estimatedCost: 0.12,
|
||||
errorMessage: 'downstream: /v1/chat upstream: /api/chat',
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:05:00.000Z')),
|
||||
billingDetails: JSON.stringify({
|
||||
breakdown: { totalCost: 0.12 },
|
||||
usage: { promptTokens: 100, completionTokens: 20 },
|
||||
}),
|
||||
}).run();
|
||||
|
||||
const logId = Number(inserted.lastInsertRowid || 0);
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/stats/proxy-logs/${logId}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
id: number;
|
||||
siteName: string | null;
|
||||
username: string | null;
|
||||
billingDetails: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
expect(body.id).toBe(logId);
|
||||
expect(body.siteName).toBe('detail-site');
|
||||
expect(body.username).toBe('detail-user');
|
||||
expect(body.billingDetails).toMatchObject({
|
||||
breakdown: { totalCost: 0.12 },
|
||||
usage: { promptTokens: 100, completionTokens: 20 },
|
||||
});
|
||||
});
|
||||
});
|
||||
+163
-15
@@ -73,6 +73,86 @@ function proxyCostSqlExpression() {
|
||||
`;
|
||||
}
|
||||
|
||||
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||
|
||||
function normalizeProxyLogPageSize(raw?: string): number {
|
||||
const parsed = Number.parseInt(raw || '50', 10);
|
||||
if (!Number.isFinite(parsed)) return 50;
|
||||
return Math.max(1, Math.min(100, parsed));
|
||||
}
|
||||
|
||||
function normalizeProxyLogOffset(raw?: string): number {
|
||||
const parsed = Number.parseInt(raw || '0', 10);
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
function normalizeProxyLogStatusFilter(raw?: string): ProxyLogStatusFilter {
|
||||
const normalized = (raw || '').trim().toLowerCase();
|
||||
if (normalized === 'success') return 'success';
|
||||
if (normalized === 'failed') return 'failed';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function normalizeProxyLogSearch(raw?: string): string {
|
||||
return (raw || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildProxyLogSearchCondition(search: string) {
|
||||
if (!search) return null;
|
||||
const likeTerm = `%${search}%`;
|
||||
return sql<boolean>`(
|
||||
lower(coalesce(${schema.proxyLogs.modelRequested}, '')) like ${likeTerm}
|
||||
or lower(coalesce(${schema.proxyLogs.modelActual}, '')) like ${likeTerm}
|
||||
)`;
|
||||
}
|
||||
|
||||
function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
|
||||
if (status === 'success') {
|
||||
return eq(schema.proxyLogs.status, 'success');
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return sql<boolean>`coalesce(${schema.proxyLogs.status}, '') <> 'success'`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildProxyLogWhereClause(params: {
|
||||
status?: ProxyLogStatusFilter;
|
||||
search?: string;
|
||||
}) {
|
||||
const conditions = [
|
||||
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
||||
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
||||
].filter((condition): condition is NonNullable<typeof condition> => condition !== null);
|
||||
|
||||
if (conditions.length === 0) return undefined;
|
||||
return conditions.length === 1 ? conditions[0] : and(...conditions);
|
||||
}
|
||||
|
||||
function toRoundedMicroNumber(value: number | null | undefined): number {
|
||||
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function mapProxyLogRow(
|
||||
row: {
|
||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||
accounts: { username?: string | null } | null;
|
||||
sites: { name?: string | null; url?: string | null } | null;
|
||||
},
|
||||
options?: { includeBillingDetails?: boolean },
|
||||
) {
|
||||
return {
|
||||
...row.proxy_logs,
|
||||
...(options?.includeBillingDetails
|
||||
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
||||
: {}),
|
||||
username: row.accounts?.username || null,
|
||||
siteName: row.sites?.name || null,
|
||||
siteUrl: row.sites?.url || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function statsRoutes(app: FastifyInstance) {
|
||||
const proxyLogBaseFields = getProxyLogBaseSelectFields();
|
||||
|
||||
@@ -204,10 +284,80 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Proxy logs
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>('/api/stats/proxy-logs', async (request) => {
|
||||
const limit = parseInt(request.query.limit || '50', 10);
|
||||
const offset = parseInt(request.query.offset || '0', 10);
|
||||
const rows = await withProxyLogSelectFields(({ fields }) => (
|
||||
app.get<{ Querystring: { limit?: string; offset?: string; status?: string; search?: string } }>('/api/stats/proxy-logs', async (request) => {
|
||||
const limit = normalizeProxyLogPageSize(request.query.limit);
|
||||
const offset = normalizeProxyLogOffset(request.query.offset);
|
||||
const status = normalizeProxyLogStatusFilter(request.query.status);
|
||||
const search = normalizeProxyLogSearch(request.query.search);
|
||||
const listWhere = buildProxyLogWhereClause({ status, search });
|
||||
const summaryWhere = buildProxyLogWhereClause({ search });
|
||||
|
||||
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
||||
let query = db.select({
|
||||
proxy_logs: fields,
|
||||
accounts: schema.accounts,
|
||||
sites: schema.sites,
|
||||
}).from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
|
||||
|
||||
if (listWhere) {
|
||||
query = query.where(listWhere) as typeof query;
|
||||
}
|
||||
|
||||
return query
|
||||
.orderBy(desc(schema.proxyLogs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
}, { includeBillingDetails: false }) as Array<{
|
||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||
accounts: { username?: string | null } | null;
|
||||
sites: { name?: string | null; url?: string | null } | null;
|
||||
}>;
|
||||
|
||||
let totalQuery = db.select({
|
||||
total: sql<number>`count(*)`,
|
||||
}).from(schema.proxyLogs);
|
||||
if (listWhere) {
|
||||
totalQuery = totalQuery.where(listWhere) as typeof totalQuery;
|
||||
}
|
||||
const totalRow = await totalQuery.get();
|
||||
|
||||
let summaryQuery = db.select({
|
||||
totalCount: sql<number>`count(*)`,
|
||||
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
|
||||
failedCount: sql<number>`coalesce(sum(case when coalesce(${schema.proxyLogs.status}, '') <> 'success' then 1 else 0 end), 0)`,
|
||||
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
|
||||
totalTokensAll: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
|
||||
}).from(schema.proxyLogs);
|
||||
if (summaryWhere) {
|
||||
summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery;
|
||||
}
|
||||
const summaryRow = await summaryQuery.get();
|
||||
|
||||
return {
|
||||
items: listRows.map((row) => mapProxyLogRow(row)),
|
||||
total: Number(totalRow?.total || 0),
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
pageSize: limit,
|
||||
summary: {
|
||||
totalCount: Number(summaryRow?.totalCount || 0),
|
||||
successCount: Number(summaryRow?.successCount || 0),
|
||||
failedCount: Number(summaryRow?.failedCount || 0),
|
||||
totalCost: toRoundedMicroNumber(summaryRow?.totalCost),
|
||||
totalTokensAll: Number(summaryRow?.totalTokensAll || 0),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/stats/proxy-logs/:id', async (request, reply) => {
|
||||
const id = Number.parseInt(request.params.id, 10);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return reply.code(400).send({ message: 'proxy log id is invalid' });
|
||||
}
|
||||
|
||||
const row = await withProxyLogSelectFields(({ fields }) => (
|
||||
db.select({
|
||||
proxy_logs: fields,
|
||||
accounts: schema.accounts,
|
||||
@@ -215,21 +365,19 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}).from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.orderBy(desc(schema.proxyLogs.createdAt))
|
||||
.limit(limit).offset(offset).all()
|
||||
), { includeBillingDetails: true }) as Array<{
|
||||
.where(eq(schema.proxyLogs.id, id))
|
||||
.get()
|
||||
), { includeBillingDetails: true }) as {
|
||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||
accounts: { username?: string | null } | null;
|
||||
sites: { name?: string | null; url?: string | null } | null;
|
||||
}>;
|
||||
} | undefined;
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row.proxy_logs,
|
||||
billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails),
|
||||
username: row.accounts?.username || null,
|
||||
siteName: row.sites?.name || null,
|
||||
siteUrl: row.sites?.url || null,
|
||||
}));
|
||||
if (!row) {
|
||||
return reply.code(404).send({ message: 'proxy log not found' });
|
||||
}
|
||||
|
||||
return mapProxyLogRow(row, { includeBillingDetails: true });
|
||||
});
|
||||
|
||||
// Models marketplace - refresh upstream models and aggregate.
|
||||
|
||||
@@ -501,7 +501,6 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
type: 'input_file',
|
||||
file_id: 'file_local_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: 'JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
@@ -707,7 +706,6 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
type: 'input_file',
|
||||
filename: 'notes.txt',
|
||||
file_data: 'aGVsbG8=',
|
||||
mime_type: 'text/plain',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user