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;
}
+95 -1
View File
@@ -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
View File
@@ -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',
},
],
},
+4 -3
View File
@@ -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))