1
0
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:
cita
2026-03-09 13:48:39 +08:00
parent 3dcf45851f
commit 136a038538
28 changed files with 1631 additions and 727 deletions
@@ -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
View File
@@ -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',
},
],
},
@@ -72,7 +72,6 @@ describe('proxyInputFileResolver', () => {
file_id: 'file-metapi-123',
filename: 'brief.pdf',
file_data: Buffer.from('%PDF-local').toString('base64'),
mime_type: 'application/pdf',
},
],
},
@@ -214,7 +214,6 @@ function toResponsesResolvedBlock(file: { fileId?: string; filename: string; fil
...(file.fileId ? { file_id: file.fileId } : {}),
filename: file.filename,
file_data: file.fileData,
mime_type: file.mimeType,
};
}
@@ -427,7 +427,6 @@ describe('convertOpenAiBodyToResponsesBody', () => {
type: 'input_file',
file_id: 'file_local_123',
filename: 'report.pdf',
mime_type: 'application/pdf',
file_data: 'JVBERi0xLjQK',
},
],
@@ -96,8 +96,6 @@ export function toResponsesInputFileBlock(file: NormalizedInputFile): Record<str
if (file.fileId) block.file_id = file.fileId;
if (file.fileData) block.file_data = parsedDataUrl?.data || file.fileData;
if (file.filename) block.filename = file.filename;
if (file.mimeType) block.mime_type = file.mimeType;
else if (parsedDataUrl?.mimeType) block.mime_type = parsedDataUrl.mimeType;
return block;
}