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,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))