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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+95
-1
@@ -84,6 +84,17 @@ async function request(url: string, options: RequestOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryString(params?: Record<string, string | number | boolean | null | undefined>) {
|
||||
if (!params) return '';
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
const serialized = searchParams.toString();
|
||||
return serialized ? `?${serialized}` : '';
|
||||
}
|
||||
|
||||
type TestChatRequestPayload = {
|
||||
model: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
@@ -130,6 +141,88 @@ export type ProxyTestJobResponse = {
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||
|
||||
export type ProxyLogBillingDetails = {
|
||||
quotaType: number;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
billablePromptTokens: number;
|
||||
promptTokensIncludeCache: boolean | null;
|
||||
};
|
||||
pricing: {
|
||||
modelRatio: number;
|
||||
completionRatio: number;
|
||||
cacheRatio: number;
|
||||
cacheCreationRatio: number;
|
||||
groupRatio: number;
|
||||
};
|
||||
breakdown: {
|
||||
inputPerMillion: number;
|
||||
outputPerMillion: number;
|
||||
cacheReadPerMillion: number;
|
||||
cacheCreationPerMillion: number;
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
cacheReadCost: number;
|
||||
cacheCreationCost: number;
|
||||
totalCost: number;
|
||||
};
|
||||
} | null;
|
||||
|
||||
export type ProxyLogListItem = {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
modelRequested: string;
|
||||
modelActual: string;
|
||||
status: string;
|
||||
latencyMs: number;
|
||||
totalTokens: number | null;
|
||||
retryCount: number;
|
||||
accountId?: number | null;
|
||||
username?: string | null;
|
||||
siteName?: string | null;
|
||||
siteUrl?: string | null;
|
||||
errorMessage?: string | null;
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
estimatedCost?: number | null;
|
||||
};
|
||||
|
||||
export type ProxyLogDetail = ProxyLogListItem & {
|
||||
routeId?: number | null;
|
||||
channelId?: number | null;
|
||||
httpStatus?: number | null;
|
||||
billingDetails?: ProxyLogBillingDetails;
|
||||
};
|
||||
|
||||
export type ProxyLogsSummary = {
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
totalCost: number;
|
||||
totalTokensAll: number;
|
||||
};
|
||||
|
||||
export type ProxyLogsQuery = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: ProxyLogStatusFilter;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type ProxyLogsResponse = {
|
||||
items: ProxyLogListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary: ProxyLogsSummary;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: () => request('/api/sites'),
|
||||
@@ -221,7 +314,8 @@ export const api = {
|
||||
|
||||
// Stats
|
||||
getDashboard: () => request('/api/stats/dashboard'),
|
||||
getProxyLogs: (params?: string) => request(`/api/stats/proxy-logs${params ? '?' + params : ''}`),
|
||||
getProxyLogs: (params?: ProxyLogsQuery) => request(`/api/stats/proxy-logs${buildQueryString(params)}`) as Promise<ProxyLogsResponse>,
|
||||
getProxyLogDetail: (id: number) => request(`/api/stats/proxy-logs/${id}`) as Promise<ProxyLogDetail>,
|
||||
checkModels: (accountId: number) => request(`/api/models/check/${accountId}`, { method: 'POST' }),
|
||||
getSiteDistribution: () => request('/api/stats/site-distribution'),
|
||||
getSiteTrend: (days = 7) => request(`/api/stats/site-trend?days=${days}`),
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import ProxyLogs from './ProxyLogs.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getProxyLogs: vi.fn(),
|
||||
getProxyLogDetail: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buildListResponse(overrides?: Partial<{
|
||||
items: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary: {
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
totalCost: number;
|
||||
totalTokensAll: number;
|
||||
};
|
||||
}>) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: 101,
|
||||
createdAt: '2026-03-09 16:00:00',
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
latencyMs: 120,
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
retryCount: 0,
|
||||
estimatedCost: 1.23,
|
||||
errorMessage: 'downstream: /v1/chat upstream: /api/chat',
|
||||
username: 'tester',
|
||||
siteName: 'main-site',
|
||||
siteUrl: 'https://main-site.example.com',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
summary: {
|
||||
totalCount: 12,
|
||||
successCount: 8,
|
||||
failedCount: 4,
|
||||
totalCost: 1.23,
|
||||
totalTokensAll: 15,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProxyLogs server-driven page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getProxyLogs.mockResolvedValue(buildListResponse());
|
||||
apiMock.getProxyLogDetail.mockResolvedValue({
|
||||
id: 101,
|
||||
createdAt: '2026-03-09 16:00:00',
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
latencyMs: 120,
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
retryCount: 0,
|
||||
estimatedCost: 1.23,
|
||||
errorMessage: 'downstream: /v1/chat upstream: /api/chat',
|
||||
username: 'tester',
|
||||
siteName: 'main-site',
|
||||
siteUrl: 'https://main-site.example.com',
|
||||
billingDetails: {
|
||||
breakdown: {
|
||||
inputPerMillion: 1,
|
||||
outputPerMillion: 2,
|
||||
cacheReadPerMillion: 0,
|
||||
cacheCreationPerMillion: 0,
|
||||
inputCost: 0.1,
|
||||
outputCost: 0.2,
|
||||
cacheReadCost: 0,
|
||||
cacheCreationCost: 0,
|
||||
totalCost: 0.3,
|
||||
},
|
||||
pricing: {
|
||||
modelRatio: 1,
|
||||
completionRatio: 1,
|
||||
cacheRatio: 0,
|
||||
cacheCreationRatio: 0,
|
||||
groupRatio: 1,
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
billablePromptTokens: 10,
|
||||
promptTokensIncludeCache: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('requests paginated data from the server and renders server summary counts', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getProxyLogs).toHaveBeenCalledWith({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
status: 'all',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('消耗总额 $1.2300');
|
||||
expect(text).toContain('全部 12');
|
||||
expect(text).toContain('成功 8');
|
||||
expect(text).toContain('失败 4');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-queries the server for status and search changes instead of filtering locally', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const failedTab = root!.root.findAll((node) => (
|
||||
node.type === 'button' && collectText(node).includes('失败')
|
||||
))[0];
|
||||
await act(async () => {
|
||||
failedTab.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const searchInput = root!.root.find((node) => (
|
||||
node.type === 'input' && node.props.placeholder === '搜索模型名称...'
|
||||
));
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'mini' } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getProxyLogs).toHaveBeenNthCalledWith(2, {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
status: 'failed',
|
||||
search: '',
|
||||
});
|
||||
expect(apiMock.getProxyLogs).toHaveBeenLastCalledWith({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
status: 'failed',
|
||||
search: 'mini',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('loads detail on first expand and reuses the cached detail on re-expand', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root!.root.find((node) => (
|
||||
node.type === 'tr' && node.props['data-testid'] === 'proxy-log-row-101'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getProxyLogDetail).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getProxyLogDetail).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.getProxyLogDetail).toHaveBeenCalledWith(101);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
+319
-302
@@ -1,5 +1,12 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import React, { useDeferredValue, useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
api,
|
||||
type ProxyLogBillingDetails,
|
||||
type ProxyLogDetail,
|
||||
type ProxyLogListItem,
|
||||
type ProxyLogsSummary,
|
||||
type ProxyLogStatusFilter,
|
||||
} from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { ModelBadge } from '../components/BrandIcon.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
@@ -7,58 +14,28 @@ import ModernSelect from '../components/ModernSelect.js';
|
||||
import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type StatusFilter = 'all' | 'success' | 'failed';
|
||||
|
||||
interface ProxyLog {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
modelRequested: string;
|
||||
modelActual: string;
|
||||
status: string;
|
||||
latencyMs: number;
|
||||
totalTokens: number | null;
|
||||
retryCount: number;
|
||||
accountId?: number;
|
||||
type ProxyLogRenderItem = ProxyLogListItem & {
|
||||
billingDetails?: ProxyLogBillingDetails;
|
||||
username?: string | null;
|
||||
siteName?: string | null;
|
||||
siteUrl?: string | null;
|
||||
errorMessage?: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
estimatedCost?: number;
|
||||
billingDetails?: {
|
||||
quotaType: number;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
billablePromptTokens: number;
|
||||
promptTokensIncludeCache: boolean | null;
|
||||
};
|
||||
pricing: {
|
||||
modelRatio: number;
|
||||
completionRatio: number;
|
||||
cacheRatio: number;
|
||||
cacheCreationRatio: number;
|
||||
groupRatio: number;
|
||||
};
|
||||
breakdown: {
|
||||
inputPerMillion: number;
|
||||
outputPerMillion: number;
|
||||
cacheReadPerMillion: number;
|
||||
cacheCreationPerMillion: number;
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
cacheReadCost: number;
|
||||
cacheCreationCost: number;
|
||||
totalCost: number;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
type ProxyLogDetailState = {
|
||||
loading: boolean;
|
||||
data?: ProxyLogDetail;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZES = [20, 50, 100];
|
||||
const EMPTY_SUMMARY: ProxyLogsSummary = {
|
||||
totalCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
totalCost: 0,
|
||||
totalTokensAll: 0,
|
||||
};
|
||||
|
||||
function formatLatency(ms: number) {
|
||||
if (ms >= 1000) {
|
||||
@@ -92,13 +69,13 @@ function formatPerMillionPrice(value: number) {
|
||||
return `$${formatCompactNumber(value)} / 1M tokens`;
|
||||
}
|
||||
|
||||
function formatBillingDetailSummary(log: ProxyLog) {
|
||||
function formatBillingDetailSummary(log: ProxyLogRenderItem) {
|
||||
const detail = log.billingDetails;
|
||||
if (!detail) return null;
|
||||
return `模型倍率 ${formatCompactNumber(detail.pricing.modelRatio)},输出倍率 ${formatCompactNumber(detail.pricing.completionRatio)},缓存倍率 ${formatCompactNumber(detail.pricing.cacheRatio)},缓存创建倍率 ${formatCompactNumber(detail.pricing.cacheCreationRatio)},分组倍率 ${formatCompactNumber(detail.pricing.groupRatio)}`;
|
||||
}
|
||||
|
||||
function buildBillingProcessLines(log: ProxyLog) {
|
||||
function buildBillingProcessLines(log: ProxyLogRenderItem) {
|
||||
const detail = log.billingDetails;
|
||||
if (!detail) return [];
|
||||
|
||||
@@ -134,73 +111,111 @@ function buildBillingProcessLines(log: ProxyLog) {
|
||||
}
|
||||
|
||||
export default function ProxyLogs() {
|
||||
const [logs, setLogs] = useState<ProxyLog[]>([]);
|
||||
const [logs, setLogs] = useState<ProxyLogListItem[]>([]);
|
||||
const [summary, setSummary] = useState<ProxyLogsSummary>(EMPTY_SUMMARY);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>('all');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const deferredSearchInput = useDeferredValue(searchInput.trim());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setSearchQuery(deferredSearchInput);
|
||||
}, [deferredSearchInput]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const currentOffset = (safePage - 1) * pageSize;
|
||||
const displayedStart = total === 0 ? 0 : currentOffset + 1;
|
||||
const displayedEnd = total === 0 ? 0 : Math.min(currentOffset + logs.length, total);
|
||||
|
||||
const pageNumbers = useMemo(() => (
|
||||
Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
if (totalPages <= 7) return i + 1;
|
||||
if (safePage <= 4) return i + 1;
|
||||
if (safePage >= totalPages - 3) return totalPages - 6 + i;
|
||||
return safePage - 3 + i;
|
||||
})
|
||||
), [safePage, totalPages]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getProxyLogs('limit=500');
|
||||
setLogs(data);
|
||||
const data = await api.getProxyLogs({
|
||||
limit: pageSize,
|
||||
offset: currentOffset,
|
||||
status: statusFilter,
|
||||
search: searchQuery,
|
||||
});
|
||||
setLogs(Array.isArray(data.items) ? data.items : []);
|
||||
setTotal(Number(data.total || 0));
|
||||
setSummary(data.summary || EMPTY_SUMMARY);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '加载日志失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [currentOffset, pageSize, searchQuery, statusFilter, toast]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/* ---- derived ---- */
|
||||
const filtered = useMemo(() => logs.filter(log => {
|
||||
if (statusFilter === 'success' && log.status !== 'success') return false;
|
||||
if (statusFilter === 'failed' && log.status === 'success') return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (log.modelRequested || '').toLowerCase().includes(q) ||
|
||||
(log.modelActual || '').toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
}), [logs, statusFilter, search]);
|
||||
useEffect(() => {
|
||||
if (page <= totalPages) return;
|
||||
setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((safePage - 1) * pageSize, safePage * pageSize),
|
||||
[filtered, safePage, pageSize],
|
||||
);
|
||||
|
||||
useEffect(() => { setPage(1); }, [statusFilter, search, pageSize]);
|
||||
|
||||
/* ---- stats ---- */
|
||||
const summary = useMemo(() => {
|
||||
let successCount = 0;
|
||||
let totalCost = 0;
|
||||
let totalTokensAll = 0;
|
||||
for (const log of logs) {
|
||||
if (log.status === 'success') successCount += 1;
|
||||
totalCost += log.estimatedCost || 0;
|
||||
totalTokensAll += log.totalTokens || 0;
|
||||
}
|
||||
const totalCount = logs.length;
|
||||
return {
|
||||
totalCount,
|
||||
successCount,
|
||||
failedCount: totalCount - successCount,
|
||||
totalCost,
|
||||
totalTokensAll,
|
||||
};
|
||||
useEffect(() => {
|
||||
setExpanded((current) => (
|
||||
current !== null && logs.some((log) => log.id === current)
|
||||
? current
|
||||
: null
|
||||
));
|
||||
}, [logs]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
const existing = detailById[id];
|
||||
if (existing?.loading || existing?.data) return;
|
||||
|
||||
setDetailById((current) => ({
|
||||
...current,
|
||||
[id]: { loading: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await api.getProxyLogDetail(id);
|
||||
setDetailById((current) => ({
|
||||
...current,
|
||||
[id]: { loading: false, data },
|
||||
}));
|
||||
} catch (e: any) {
|
||||
const message = e?.message || '加载日志详情失败';
|
||||
setDetailById((current) => ({
|
||||
...current,
|
||||
[id]: { loading: false, error: message },
|
||||
}));
|
||||
toast.error(message);
|
||||
}
|
||||
}, [detailById, toast]);
|
||||
|
||||
const handleToggleExpand = useCallback((id: number) => {
|
||||
const shouldExpand = expanded !== id;
|
||||
setExpanded(shouldExpand ? id : null);
|
||||
if (shouldExpand) {
|
||||
void loadDetail(id);
|
||||
}
|
||||
}, [expanded, loadDetail]);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header + Stat Badges */}
|
||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<h2 className="page-title">{tr('使用日志')}</h2>
|
||||
@@ -219,18 +234,20 @@ export default function ProxyLogs() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: Filter + Search */}
|
||||
<div className="toolbar" style={{ marginBottom: 12 }}>
|
||||
<div className="pill-tabs">
|
||||
{([
|
||||
{ key: 'all' as StatusFilter, label: '全部', count: summary.totalCount },
|
||||
{ key: 'success' as StatusFilter, label: '成功', count: summary.successCount },
|
||||
{ key: 'failed' as StatusFilter, label: '失败', count: summary.failedCount },
|
||||
]).map(tab => (
|
||||
{ key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount },
|
||||
{ key: 'success' as ProxyLogStatusFilter, label: '成功', count: summary.successCount },
|
||||
{ key: 'failed' as ProxyLogStatusFilter, label: '失败', count: summary.failedCount },
|
||||
]).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`pill-tab ${statusFilter === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
onClick={() => {
|
||||
setStatusFilter(tab.key);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{tab.label} <span style={{ fontVariantNumeric: 'tabular-nums', opacity: 0.7 }}>{tab.count}</span>
|
||||
</button>
|
||||
@@ -240,11 +257,10 @@ export default function ProxyLogs() {
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索模型名称..." />
|
||||
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
@@ -275,194 +291,201 @@ export default function ProxyLogs() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map(log => {
|
||||
const pathMeta = parseProxyLogPathMeta(log.errorMessage);
|
||||
const billingDetailSummary = formatBillingDetailSummary(log);
|
||||
const billingProcessLines = buildBillingProcessLines(log);
|
||||
{logs.map((log) => {
|
||||
const detailState = detailById[log.id];
|
||||
const detail = detailState?.data;
|
||||
const detailLog: ProxyLogRenderItem = detail ? { ...log, ...detail } : log;
|
||||
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
|
||||
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
|
||||
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
|
||||
|
||||
return (
|
||||
<React.Fragment key={log.id}>
|
||||
<tr
|
||||
onClick={() => setExpanded(expanded === log.id ? null : log.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: expanded === log.id ? 'var(--color-primary-light)' : undefined,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '8px 4px 8px 12px' }}>
|
||||
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{
|
||||
transform: expanded === log.id ? 'rotate(90deg)' : 'none',
|
||||
transition: 'transform 0.2s',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||
{formatDateTimeLocal(log.createdAt)}
|
||||
</td>
|
||||
<td>
|
||||
<ModelBadge model={log.modelRequested} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)',
|
||||
}} />
|
||||
{log.status === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: latencyColor(log.latencyMs),
|
||||
background: latencyBgColor(log.latencyMs),
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
{formatLatency(log.latencyMs)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||
{log.promptTokens?.toLocaleString() || '-'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||
{log.completionTokens?.toLocaleString() || '-'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', fontWeight: 500 }}>
|
||||
{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{log.retryCount > 0 ? (
|
||||
<span className="badge badge-warning" style={{ fontSize: 11 }}>{log.retryCount}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === log.id && (
|
||||
<tr style={{ background: 'var(--color-bg)' }}>
|
||||
<td colSpan={9} style={{ padding: 0 }}>
|
||||
<div className="anim-collapse is-open">
|
||||
<div className="anim-collapse-inner">
|
||||
<div className="animate-fade-in" style={{
|
||||
padding: '14px 20px 14px 40px',
|
||||
borderTop: '1px solid var(--color-border-light)',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.9,
|
||||
color: 'var(--color-text-secondary)',
|
||||
<React.Fragment key={log.id}>
|
||||
<tr
|
||||
data-testid={`proxy-log-row-${log.id}`}
|
||||
onClick={() => handleToggleExpand(log.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: expanded === log.id ? 'var(--color-primary-light)' : undefined,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '8px 4px 8px 12px' }}>
|
||||
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{
|
||||
transform: expanded === log.id ? 'rotate(90deg)' : 'none',
|
||||
transition: 'transform 0.2s',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}>
|
||||
{/* 鏃ュ織璇︽儏 section */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>日志详情</span>
|
||||
<div>
|
||||
<div>
|
||||
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelRequested}</strong>
|
||||
{log.modelActual && log.modelActual !== log.modelRequested && (
|
||||
<>{' -> '}实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelActual}</strong></>
|
||||
)}
|
||||
,状态: <strong style={{ color: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{log.status === 'success' ? '成功' : '失败'}</strong>
|
||||
,用时: <strong style={{ color: latencyColor(log.latencyMs) }}>{formatLatency(log.latencyMs)}</strong>
|
||||
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{log.siteName || '未知站点'}</strong>
|
||||
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{log.username || '未知账号'}</strong>
|
||||
</div>
|
||||
{billingDetailSummary && (
|
||||
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.billingDetails && log.billingDetails.usage.cacheReadTokens > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存 Tokens</span>
|
||||
<span>{log.billingDetails.usage.cacheReadTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.billingDetails && log.billingDetails.usage.cacheCreationTokens > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存创建 Tokens</span>
|
||||
<span>{log.billingDetails.usage.cacheCreationTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 璁¤垂杩囩▼ section */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-info)', flexShrink: 0 }}>计费过程</span>
|
||||
{billingProcessLines.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{billingProcessLines.map((line, index) => (
|
||||
<span key={`${log.id}-billing-${index}`}>{line}</span>
|
||||
))}
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>仅供参考,以实际扣费为准</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
输入 {log.promptTokens?.toLocaleString() || 0} tokens
|
||||
{' + '}输出 {log.completionTokens?.toLocaleString() || 0} tokens
|
||||
{' = '}总计 {log.totalTokens?.toLocaleString() || 0} tokens
|
||||
{typeof log.estimatedCost === 'number' && (
|
||||
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${log.estimatedCost.toFixed(6)}</strong></>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 涓嬫父璇锋眰璺緞 */}
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>下游请求路径</span>
|
||||
{pathMeta.downstreamPath ? (
|
||||
<code style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||
{formatDateTimeLocal(log.createdAt)}
|
||||
</td>
|
||||
<td>
|
||||
<ModelBadge model={log.modelRequested} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)',
|
||||
}} />
|
||||
{log.status === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: latencyColor(log.latencyMs),
|
||||
background: latencyBgColor(log.latencyMs),
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
{formatLatency(log.latencyMs)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||
{log.promptTokens?.toLocaleString() || '-'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||
{log.completionTokens?.toLocaleString() || '-'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', fontWeight: 500 }}>
|
||||
{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{log.retryCount > 0 ? (
|
||||
<span className="badge badge-warning" style={{ fontSize: 11 }}>{log.retryCount}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === log.id && (
|
||||
<tr style={{ background: 'var(--color-bg)' }}>
|
||||
<td colSpan={9} style={{ padding: 0 }}>
|
||||
<div className="anim-collapse is-open">
|
||||
<div className="anim-collapse-inner">
|
||||
<div className="animate-fade-in" style={{
|
||||
padding: '14px 20px 14px 40px',
|
||||
borderTop: '1px solid var(--color-border-light)',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.9,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
{pathMeta.downstreamPath}
|
||||
</code>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>未记录</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>日志详情</span>
|
||||
<div>
|
||||
<div>
|
||||
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{detailLog.modelRequested}</strong>
|
||||
{detailLog.modelActual && detailLog.modelActual !== detailLog.modelRequested && (
|
||||
<>{' -> '}实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{detailLog.modelActual}</strong></>
|
||||
)}
|
||||
,状态: <strong style={{ color: detailLog.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{detailLog.status === 'success' ? '成功' : '失败'}</strong>
|
||||
,用时: <strong style={{ color: latencyColor(detailLog.latencyMs) }}>{formatLatency(detailLog.latencyMs)}</strong>
|
||||
{detail && (
|
||||
<>
|
||||
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{detailLog.siteName || '未知站点'}</strong>
|
||||
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{detailLog.username || '未知账号'}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
|
||||
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
|
||||
{billingDetailSummary && (
|
||||
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 涓婃父璇锋眰璺緞 */}
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>上游请求路径</span>
|
||||
{pathMeta.upstreamPath ? (
|
||||
<code style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
}}>
|
||||
{pathMeta.upstreamPath}
|
||||
</code>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>未记录</span>
|
||||
)}
|
||||
</div>
|
||||
{detailLog.billingDetails && detailLog.billingDetails.usage.cacheReadTokens > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存 Tokens</span>
|
||||
<span>{detailLog.billingDetails.usage.cacheReadTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 閿欒淇℃伅 */}
|
||||
{pathMeta.errorMessage.trim().length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-danger)', flexShrink: 0 }}>错误信息</span>
|
||||
<span style={{ color: 'var(--color-danger)' }}>{pathMeta.errorMessage}</span>
|
||||
{detailLog.billingDetails && detailLog.billingDetails.usage.cacheCreationTokens > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存创建 Tokens</span>
|
||||
<span>{detailLog.billingDetails.usage.cacheCreationTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-info)', flexShrink: 0 }}>计费过程</span>
|
||||
{billingProcessLines.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{billingProcessLines.map((line, index) => (
|
||||
<span key={`${log.id}-billing-${index}`}>{line}</span>
|
||||
))}
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>仅供参考,以实际扣费为准</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
输入 {detailLog.promptTokens?.toLocaleString() || 0} tokens
|
||||
{' + '}输出 {detailLog.completionTokens?.toLocaleString() || 0} tokens
|
||||
{' = '}总计 {detailLog.totalTokens?.toLocaleString() || 0} tokens
|
||||
{typeof detailLog.estimatedCost === 'number' && (
|
||||
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${detailLog.estimatedCost.toFixed(6)}</strong></>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>下游请求路径</span>
|
||||
{detail && pathMeta.downstreamPath ? (
|
||||
<code style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
}}>
|
||||
{pathMeta.downstreamPath}
|
||||
</code>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>未记录</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>上游请求路径</span>
|
||||
{detail && pathMeta.upstreamPath ? (
|
||||
<code style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
}}>
|
||||
{pathMeta.upstreamPath}
|
||||
</code>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>未记录</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail && pathMeta.errorMessage.trim().length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-danger)', flexShrink: 0 }}>错误信息</span>
|
||||
<span style={{ color: 'var(--color-danger)' }}>{pathMeta.errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)})}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
{!loading && logs.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
|
||||
<div className="empty-state-title">{tr('暂无使用日志')}</div>
|
||||
@@ -471,28 +494,20 @@ export default function ProxyLogs() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{filtered.length > 0 && (
|
||||
{total > 0 && (
|
||||
<div className="pagination">
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginRight: 'auto' }}>
|
||||
显示第 {(safePage - 1) * pageSize + 1} - {Math.min(safePage * pageSize, filtered.length)} 条,共 {filtered.length} 条
|
||||
显示第 {displayedStart} - {displayedEnd} 条,共 {total} 条
|
||||
</div>
|
||||
<button className="pagination-btn" disabled={safePage <= 1} onClick={() => setPage(p => p - 1)}>
|
||||
<button className="pagination-btn" disabled={safePage <= 1} onClick={() => setPage((current) => current - 1)}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
let num: number;
|
||||
if (totalPages <= 7) num = i + 1;
|
||||
else if (safePage <= 4) num = i + 1;
|
||||
else if (safePage >= totalPages - 3) num = totalPages - 6 + i;
|
||||
else num = safePage - 3 + i;
|
||||
return (
|
||||
<button key={num} className={`pagination-btn ${safePage === num ? 'active' : ''}`} onClick={() => setPage(num)}>
|
||||
{num}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button className="pagination-btn" disabled={safePage >= totalPages} onClick={() => setPage(p => p + 1)}>
|
||||
{pageNumbers.map((num) => (
|
||||
<button key={num} className={`pagination-btn ${safePage === num ? 'active' : ''}`} onClick={() => setPage(num)}>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
<button className="pagination-btn" disabled={safePage >= totalPages} onClick={() => setPage((current) => current + 1)}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
<div className="pagination-size">
|
||||
@@ -501,7 +516,10 @@ export default function ProxyLogs() {
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(pageSize)}
|
||||
onChange={(nextValue) => setPageSize(Number(nextValue))}
|
||||
onChange={(nextValue) => {
|
||||
setPageSize(Number(nextValue));
|
||||
setPage(1);
|
||||
}}
|
||||
options={PAGE_SIZES.map((s) => ({ value: String(s), label: String(s) }))}
|
||||
placeholder={String(pageSize)}
|
||||
/>
|
||||
@@ -512,4 +530,3 @@ export default function ProxyLogs() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -252,7 +252,42 @@ describe('modelTesterSession', () => {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: '请总结上传文件' },
|
||||
{ type: 'input_file', file_id: 'file_123', filename: 'notes.txt', mime_type: 'text/plain' },
|
||||
{ type: 'input_file', file_id: 'file_123', filename: 'notes.txt' },
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes assistant history as output_text for responses payloads', () => {
|
||||
const payload = buildApiPayload(
|
||||
[
|
||||
{ id: 'u1', role: 'user', content: '先看附件', createAt: 1 },
|
||||
{ id: 'a1', role: 'assistant', content: '我已经看过摘要', createAt: 2 },
|
||||
],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-5.2',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'gpt-5.2',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: '先看附件' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'output_text', text: '我已经看过摘要' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -357,7 +392,6 @@ describe('modelTesterSession', () => {
|
||||
type: 'input_file',
|
||||
file_id: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -343,7 +343,6 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
|
||||
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
|
||||
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
|
||||
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
|
||||
if (typeof part.mimeType === 'string' && part.mimeType.trim()) fileBlock.mime_type = part.mimeType.trim();
|
||||
if (Object.keys(fileBlock).length === 1) return null;
|
||||
return fileBlock;
|
||||
}
|
||||
@@ -383,10 +382,12 @@ const toResponsesInput = (messages: ApiChatMessage[]) => {
|
||||
if (!hasStructuredParts && messages.length === 1 && messages[0].role === 'user') {
|
||||
return messages[0].content;
|
||||
}
|
||||
const toResponsesTextType = (role: ApiChatMessage['role']) =>
|
||||
role === 'assistant' ? 'output_text' : 'input_text';
|
||||
return messages.map((message) => ({
|
||||
role: message.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: [
|
||||
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
|
||||
...(message.content.trim() ? [{ type: toResponsesTextType(message.role), text: message.content }] : []),
|
||||
...((Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toResponsesContentPart(part))
|
||||
@@ -394,7 +395,7 @@ const toResponsesInput = (messages: ApiChatMessage[]) => {
|
||||
: [])),
|
||||
].length > 0
|
||||
? [
|
||||
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
|
||||
...(message.content.trim() ? [{ type: toResponsesTextType(message.role), text: message.content }] : []),
|
||||
...((Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toResponsesContentPart(part))
|
||||
|
||||
Reference in New Issue
Block a user