mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
12 Commits
@@ -936,6 +936,15 @@ export function VisualConfigEditor({
|
||||
disabled={disabled}
|
||||
onChange={(quotaSwitchPreviewModel) => onChange({ quotaSwitchPreviewModel })}
|
||||
/>
|
||||
<ToggleRow
|
||||
title={t('config_management.visual.sections.quota.antigravity_credits')}
|
||||
description={t(
|
||||
'config_management.visual.sections.quota.antigravity_credits_desc'
|
||||
)}
|
||||
checked={values.quotaAntigravityCredits}
|
||||
disabled={disabled}
|
||||
onChange={(quotaAntigravityCredits) => onChange({ quotaAntigravityCredits })}
|
||||
/>
|
||||
</SectionGrid>
|
||||
</ConfigSection>
|
||||
|
||||
|
||||
@@ -733,6 +733,7 @@ const renderAntigravityItems = (
|
||||
};
|
||||
|
||||
const PREMIUM_GEMINI_CLI_TIER_IDS = new Set(['g1-ultra-tier']);
|
||||
const PREMIUM_CODEX_PLAN_TYPES = new Set(['pro', 'prolite', 'pro-lite', 'pro_lite']);
|
||||
|
||||
const renderCodexItems = (
|
||||
quota: CodexQuotaState,
|
||||
@@ -748,6 +749,9 @@ const renderCodexItems = (
|
||||
const normalized = normalizePlanType(pt);
|
||||
if (!normalized) return null;
|
||||
if (normalized === 'pro') return t('codex_quota.plan_pro');
|
||||
if (PREMIUM_CODEX_PLAN_TYPES.has(normalized) && normalized !== 'pro') {
|
||||
return t('codex_quota.plan_prolite');
|
||||
}
|
||||
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
||||
if (normalized === 'team') return t('codex_quota.plan_team');
|
||||
if (normalized === 'free') return t('codex_quota.plan_free');
|
||||
@@ -755,7 +759,7 @@ const renderCodexItems = (
|
||||
};
|
||||
|
||||
const planLabel = getPlanLabel(planType);
|
||||
const isPremiumPlan = normalizePlanType(planType) === 'pro';
|
||||
const isPremiumPlan = PREMIUM_CODEX_PLAN_TYPES.has(normalizePlanType(planType) ?? '');
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planLabel) {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatCompactNumber, formatUsd } from '@/utils/usage';
|
||||
import {
|
||||
LATENCY_SOURCE_FIELD,
|
||||
formatCompactNumber,
|
||||
formatDurationMs,
|
||||
formatUsd,
|
||||
type ModelStatsSummary,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ModelStat {
|
||||
model: string;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
export type ModelStat = ModelStatsSummary;
|
||||
|
||||
export interface ModelStatsCardProps {
|
||||
modelStats: ModelStat[];
|
||||
@@ -19,7 +18,14 @@ export interface ModelStatsCardProps {
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
|
||||
type SortKey =
|
||||
| 'model'
|
||||
| 'requests'
|
||||
| 'tokens'
|
||||
| 'cost'
|
||||
| 'successRate'
|
||||
| 'averageLatencyMs'
|
||||
| 'totalLatencyMs';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
interface ModelStatWithRate extends ModelStat {
|
||||
@@ -30,6 +36,10 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>('requests');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const latencyHint = t('usage_stats.latency_unit_hint', {
|
||||
field: LATENCY_SOURCE_FIELD,
|
||||
unit: t('usage_stats.duration_unit_ms'),
|
||||
});
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
@@ -48,109 +58,159 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
list.sort((a, b) => {
|
||||
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
|
||||
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
|
||||
const left = a[sortKey];
|
||||
const right = b[sortKey];
|
||||
const leftValid = typeof left === 'number' && Number.isFinite(left);
|
||||
const rightValid = typeof right === 'number' && Number.isFinite(right);
|
||||
|
||||
if (!leftValid && !rightValid) return 0;
|
||||
if (!leftValid) return 1;
|
||||
if (!rightValid) return -1;
|
||||
return dir * (left - right);
|
||||
});
|
||||
return list;
|
||||
}, [modelStats, sortKey, sortDir]);
|
||||
|
||||
const arrow = (key: SortKey) =>
|
||||
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
const arrow = (key: SortKey) => (sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '');
|
||||
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
|
||||
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||
const hasLatencyData = sorted.some((stat) => stat.latencySampleCount > 0);
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : sorted.length > 0 ? (
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('model')}
|
||||
>
|
||||
{t('usage_stats.model_name')}{arrow('model')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('requests')}
|
||||
>
|
||||
{t('usage_stats.requests_count')}{arrow('requests')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('tokens')}
|
||||
>
|
||||
{t('usage_stats.tokens_count')}{arrow('tokens')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('successRate')}
|
||||
>
|
||||
{t('usage_stats.success_rate')}{arrow('successRate')}
|
||||
</button>
|
||||
</th>
|
||||
{hasPrices && (
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
|
||||
<>
|
||||
{hasLatencyData && <div className={styles.detailsNote}>{latencyHint}</div>}
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('cost')}
|
||||
onClick={() => handleSort('model')}
|
||||
>
|
||||
{t('usage_stats.total_cost')}{arrow('cost')}
|
||||
{t('usage_stats.model_name')}
|
||||
{arrow('model')}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatCompactNumber(stat.tokens)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
stat.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: stat.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('requests')}
|
||||
>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
{t('usage_stats.requests_count')}
|
||||
{arrow('requests')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('tokens')}
|
||||
>
|
||||
{t('usage_stats.tokens_count')}
|
||||
{arrow('tokens')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('averageLatencyMs')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('averageLatencyMs')}
|
||||
title={latencyHint}
|
||||
>
|
||||
{t('usage_stats.avg_time')}
|
||||
{arrow('averageLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('totalLatencyMs')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('totalLatencyMs')}
|
||||
title={latencyHint}
|
||||
>
|
||||
{t('usage_stats.total_time')}
|
||||
{arrow('totalLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('successRate')}
|
||||
>
|
||||
{t('usage_stats.success_rate')}
|
||||
{arrow('successRate')}
|
||||
</button>
|
||||
</th>
|
||||
{hasPrices && (
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('cost')}
|
||||
>
|
||||
{t('usage_stats.total_cost')}
|
||||
{arrow('cost')}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(
|
||||
<span className={styles.statSuccess}>
|
||||
{stat.successCount.toLocaleString()}
|
||||
</span>{' '}
|
||||
<span className={styles.statFailure}>
|
||||
{stat.failureCount.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatCompactNumber(stat.tokens)}</td>
|
||||
<td className={styles.durationCell}>
|
||||
{formatDurationMs(stat.averageLatencyMs)}
|
||||
</td>
|
||||
<td className={styles.durationCell}>
|
||||
{formatDurationMs(stat.totalLatencyMs)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
stat.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: stat.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
|
||||
@@ -11,8 +11,11 @@ import type { CredentialInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
import {
|
||||
collectUsageDetails,
|
||||
extractLatencyMs,
|
||||
extractTotalTokens,
|
||||
normalizeAuthIndex
|
||||
formatDurationMs,
|
||||
LATENCY_SOURCE_FIELD,
|
||||
normalizeAuthIndex,
|
||||
} from '@/utils/usage';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
@@ -31,6 +34,7 @@ type RequestEventRow = {
|
||||
sourceType: string;
|
||||
authIndex: string;
|
||||
failed: boolean;
|
||||
latencyMs: number | null;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
@@ -68,9 +72,13 @@ export function RequestEventsDetailsCard({
|
||||
claudeConfigs,
|
||||
codexConfigs,
|
||||
vertexConfigs,
|
||||
openaiProviders
|
||||
openaiProviders,
|
||||
}: RequestEventsDetailsCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const latencyHint = t('usage_stats.latency_unit_hint', {
|
||||
field: LATENCY_SOURCE_FIELD,
|
||||
unit: t('usage_stats.duration_unit_ms'),
|
||||
});
|
||||
|
||||
const [modelFilter, setModelFilter] = useState(ALL_FILTER);
|
||||
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
|
||||
@@ -91,7 +99,7 @@ export function RequestEventsDetailsCard({
|
||||
if (!key) return;
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString()
|
||||
type: (file.type || file.provider || '').toString(),
|
||||
});
|
||||
});
|
||||
setAuthFileMap(map);
|
||||
@@ -131,7 +139,12 @@ export function RequestEventsDetailsCard({
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
|
||||
const sourceInfo = resolveSourceDisplay(
|
||||
sourceRaw,
|
||||
authIndexRaw,
|
||||
sourceInfoMap,
|
||||
authFileMap
|
||||
);
|
||||
const source = sourceInfo.displayName;
|
||||
const sourceType = sourceInfo.type;
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
@@ -146,6 +159,7 @@ export function RequestEventsDetailsCard({
|
||||
toNumber(detail.tokens?.total_tokens),
|
||||
extractTotalTokens(detail)
|
||||
);
|
||||
const latencyMs = extractLatencyMs(detail);
|
||||
|
||||
return {
|
||||
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
|
||||
@@ -158,23 +172,26 @@ export function RequestEventsDetailsCard({
|
||||
sourceType,
|
||||
authIndex,
|
||||
failed: detail.failed === true,
|
||||
latencyMs,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
totalTokens
|
||||
totalTokens,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.timestampMs - a.timestampMs);
|
||||
}, [authFileMap, i18n.language, sourceInfoMap, usage]);
|
||||
|
||||
const hasLatencyData = useMemo(() => rows.some((row) => row.latencyMs !== null), [rows]);
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() => [
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.model))).map((model) => ({
|
||||
value: model,
|
||||
label: model
|
||||
}))
|
||||
label: model,
|
||||
})),
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
@@ -184,8 +201,8 @@ export function RequestEventsDetailsCard({
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({
|
||||
value: source,
|
||||
label: source
|
||||
}))
|
||||
label: source,
|
||||
})),
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
@@ -195,8 +212,8 @@ export function RequestEventsDetailsCard({
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.authIndex))).map((authIndex) => ({
|
||||
value: authIndex,
|
||||
label: authIndex
|
||||
}))
|
||||
label: authIndex,
|
||||
})),
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
@@ -223,8 +240,10 @@ export function RequestEventsDetailsCard({
|
||||
const filteredRows = useMemo(
|
||||
() =>
|
||||
rows.filter((row) => {
|
||||
const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
|
||||
const sourceMatched = effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
|
||||
const modelMatched =
|
||||
effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
|
||||
const sourceMatched =
|
||||
effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
|
||||
const authIndexMatched =
|
||||
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
|
||||
return modelMatched && sourceMatched && authIndexMatched;
|
||||
@@ -232,10 +251,7 @@ export function RequestEventsDetailsCard({
|
||||
[effectiveAuthIndexFilter, effectiveModelFilter, effectiveSourceFilter, rows]
|
||||
);
|
||||
|
||||
const renderedRows = useMemo(
|
||||
() => filteredRows.slice(0, MAX_RENDERED_EVENTS),
|
||||
[filteredRows]
|
||||
);
|
||||
const renderedRows = useMemo(() => filteredRows.slice(0, MAX_RENDERED_EVENTS), [filteredRows]);
|
||||
|
||||
const hasActiveFilters =
|
||||
effectiveModelFilter !== ALL_FILTER ||
|
||||
@@ -258,11 +274,12 @@ export function RequestEventsDetailsCard({
|
||||
'source_raw',
|
||||
'auth_index',
|
||||
'result',
|
||||
...(hasLatencyData ? ['latency_ms'] : []),
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'reasoning_tokens',
|
||||
'cached_tokens',
|
||||
'total_tokens'
|
||||
'total_tokens',
|
||||
];
|
||||
|
||||
const csvRows = filteredRows.map((row) =>
|
||||
@@ -273,11 +290,12 @@ export function RequestEventsDetailsCard({
|
||||
row.sourceRaw,
|
||||
row.authIndex,
|
||||
row.failed ? 'failed' : 'success',
|
||||
...(hasLatencyData ? [row.latencyMs ?? ''] : []),
|
||||
row.inputTokens,
|
||||
row.outputTokens,
|
||||
row.reasoningTokens,
|
||||
row.cachedTokens,
|
||||
row.totalTokens
|
||||
row.totalTokens,
|
||||
]
|
||||
.map((value) => encodeCsv(value))
|
||||
.join(',')
|
||||
@@ -287,7 +305,7 @@ export function RequestEventsDetailsCard({
|
||||
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
downloadBlob({
|
||||
filename: `usage-events-${fileTime}.csv`,
|
||||
blob: new Blob([content], { type: 'text/csv;charset=utf-8' })
|
||||
blob: new Blob([content], { type: 'text/csv;charset=utf-8' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -301,20 +319,21 @@ export function RequestEventsDetailsCard({
|
||||
source_raw: row.sourceRaw,
|
||||
auth_index: row.authIndex,
|
||||
failed: row.failed,
|
||||
...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
|
||||
tokens: {
|
||||
input_tokens: row.inputTokens,
|
||||
output_tokens: row.outputTokens,
|
||||
reasoning_tokens: row.reasoningTokens,
|
||||
cached_tokens: row.cachedTokens,
|
||||
total_tokens: row.totalTokens
|
||||
}
|
||||
total_tokens: row.totalTokens,
|
||||
},
|
||||
}));
|
||||
|
||||
const content = JSON.stringify(payload, null, 2);
|
||||
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
downloadBlob({
|
||||
filename: `usage-events-${fileTime}.json`,
|
||||
blob: new Blob([content], { type: 'application/json;charset=utf-8' })
|
||||
blob: new Blob([content], { type: 'application/json;charset=utf-8' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -408,11 +427,12 @@ export function RequestEventsDetailsCard({
|
||||
<>
|
||||
<div className={styles.requestEventsMeta}>
|
||||
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
|
||||
{hasLatencyData && <span className={styles.requestEventsLimitHint}>{latencyHint}</span>}
|
||||
{filteredRows.length > MAX_RENDERED_EVENTS && (
|
||||
<span className={styles.requestEventsLimitHint}>
|
||||
{t('usage_stats.request_events_limit_hint', {
|
||||
shown: MAX_RENDERED_EVENTS,
|
||||
total: filteredRows.length
|
||||
total: filteredRows.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -427,6 +447,7 @@ export function RequestEventsDetailsCard({
|
||||
<th>{t('usage_stats.request_events_source')}</th>
|
||||
<th>{t('usage_stats.request_events_auth_index')}</th>
|
||||
<th>{t('usage_stats.request_events_result')}</th>
|
||||
{hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</th>}
|
||||
<th>{t('usage_stats.input_tokens')}</th>
|
||||
<th>{t('usage_stats.output_tokens')}</th>
|
||||
<th>{t('usage_stats.reasoning_tokens')}</th>
|
||||
@@ -452,11 +473,18 @@ export function RequestEventsDetailsCard({
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={row.failed ? styles.requestEventsResultFailed : styles.requestEventsResultSuccess}
|
||||
className={
|
||||
row.failed
|
||||
? styles.requestEventsResultFailed
|
||||
: styles.requestEventsResultSuccess
|
||||
}
|
||||
>
|
||||
{row.failed ? t('stats.failure') : t('stats.success')}
|
||||
</span>
|
||||
</td>
|
||||
{hasLatencyData && (
|
||||
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
|
||||
)}
|
||||
<td>{row.inputTokens.toLocaleString()}</td>
|
||||
<td>{row.outputTokens.toLocaleString()}</td>
|
||||
<td>{row.reasoningTokens.toLocaleString()}</td>
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||
import {
|
||||
IconDiamond,
|
||||
IconDollarSign,
|
||||
IconSatellite,
|
||||
IconTimer,
|
||||
IconTrendingUp,
|
||||
} from '@/components/ui/icons';
|
||||
import {
|
||||
LATENCY_SOURCE_FIELD,
|
||||
calculateLatencyStatsFromDetails,
|
||||
calculateCost,
|
||||
formatCompactNumber,
|
||||
formatDurationMs,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateCost,
|
||||
collectUsageDetails,
|
||||
extractTotalTokens,
|
||||
type ModelPrice
|
||||
type ModelPrice,
|
||||
} from '@/utils/usage';
|
||||
import { sparklineOptions } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
@@ -44,20 +53,31 @@ export interface StatCardsProps {
|
||||
|
||||
export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: StatCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
const latencyHint = t('usage_stats.latency_unit_hint', {
|
||||
field: LATENCY_SOURCE_FIELD,
|
||||
unit: t('usage_stats.duration_unit_ms'),
|
||||
});
|
||||
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
const { tokenBreakdown, rateStats, totalCost } = useMemo(() => {
|
||||
const { tokenBreakdown, rateStats, totalCost, latencyStats } = useMemo(() => {
|
||||
const empty = {
|
||||
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
|
||||
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
|
||||
totalCost: 0
|
||||
totalCost: 0,
|
||||
latencyStats: {
|
||||
averageMs: null as number | null,
|
||||
totalMs: null as number | null,
|
||||
sampleCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
if (!usage) return empty;
|
||||
const details = collectUsageDetails(usage);
|
||||
if (!details.length) return empty;
|
||||
|
||||
const latencyStats = calculateLatencyStatsFromDetails(details);
|
||||
|
||||
let cachedTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
let totalCost = 0;
|
||||
@@ -80,7 +100,12 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
}
|
||||
|
||||
const timestamp = detail.__timestampMs ?? 0;
|
||||
if (hasValidNow && Number.isFinite(timestamp) && timestamp >= windowStart && timestamp <= now) {
|
||||
if (
|
||||
hasValidNow &&
|
||||
Number.isFinite(timestamp) &&
|
||||
timestamp >= windowStart &&
|
||||
timestamp <= now
|
||||
) {
|
||||
requestCount += 1;
|
||||
tokenCount += extractTotalTokens(detail);
|
||||
}
|
||||
@@ -98,9 +123,10 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
tpm: tokenCount / denominator,
|
||||
windowMinutes,
|
||||
requestCount,
|
||||
tokenCount
|
||||
tokenCount,
|
||||
},
|
||||
totalCost
|
||||
totalCost,
|
||||
latencyStats,
|
||||
};
|
||||
}, [hasPrices, modelPrices, nowMs, usage]);
|
||||
|
||||
@@ -123,9 +149,15 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
<span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
|
||||
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||
</span>
|
||||
{latencyStats.sampleCount > 0 && (
|
||||
<span className={styles.statMetaItem} title={latencyHint}>
|
||||
{t('usage_stats.avg_time')}:{' '}
|
||||
{loading ? '-' : formatDurationMs(latencyStats.averageMs)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
trend: sparklines.requests
|
||||
trend: sparklines.requests,
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
@@ -138,14 +170,16 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
|
||||
{t('usage_stats.cached_tokens')}:{' '}
|
||||
{loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
|
||||
</span>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
|
||||
{t('usage_stats.reasoning_tokens')}:{' '}
|
||||
{loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
trend: sparklines.tokens
|
||||
trend: sparklines.tokens,
|
||||
},
|
||||
{
|
||||
key: 'rpm',
|
||||
@@ -157,10 +191,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
|
||||
{t('usage_stats.total_requests')}:{' '}
|
||||
{loading ? '-' : rateStats.requestCount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.rpm
|
||||
trend: sparklines.rpm,
|
||||
},
|
||||
{
|
||||
key: 'tpm',
|
||||
@@ -172,10 +207,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
|
||||
{t('usage_stats.total_tokens')}:{' '}
|
||||
{loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.tpm
|
||||
trend: sparklines.tpm,
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
@@ -188,7 +224,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
|
||||
{t('usage_stats.total_tokens')}:{' '}
|
||||
{loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
|
||||
</span>
|
||||
{!hasPrices && (
|
||||
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||
@@ -197,8 +234,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
)}
|
||||
</>
|
||||
),
|
||||
trend: hasPrices ? sparklines.cost : null
|
||||
}
|
||||
trend: hasPrices ? sparklines.cost : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -211,7 +248,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
{
|
||||
'--accent': card.accent,
|
||||
'--accent-soft': card.accentSoft,
|
||||
'--accent-border': card.accentBorder
|
||||
'--accent-border': card.accentBorder,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -225,7 +262,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
|
||||
<div className={styles.statTrend}>
|
||||
{card.trend ? (
|
||||
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
|
||||
<Line
|
||||
className={styles.sparkline}
|
||||
data={card.trend.data}
|
||||
options={sparklineOptions}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.statTrendPlaceholder}></div>
|
||||
)}
|
||||
|
||||
@@ -65,7 +65,13 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
<Button
|
||||
onClick={onSave}
|
||||
loading={editor?.saving === true}
|
||||
disabled={disableControls || editor?.saving === true || !dirty || !editor?.json}
|
||||
disabled={
|
||||
disableControls ||
|
||||
editor?.saving === true ||
|
||||
!dirty ||
|
||||
!editor?.json ||
|
||||
Boolean(editor?.headersTouched && editor.headersError)
|
||||
}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
@@ -138,6 +144,20 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
/>
|
||||
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('auth_files.headers_label')}</label>
|
||||
<textarea
|
||||
className={`input ${editor.headersError ? styles.prefixProxyTextareaInvalid : ''}`}
|
||||
value={editor.headersText}
|
||||
placeholder={t('auth_files.headers_placeholder')}
|
||||
rows={4}
|
||||
aria-invalid={Boolean(editor.headersError)}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('headersText', e.target.value)}
|
||||
/>
|
||||
{editor.headersError && <div className="error-box">{editor.headersError}</div>}
|
||||
<div className="hint">{t('auth_files.headers_hint')}</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('auth_files.disable_cooling_label')}
|
||||
value={editor.disableCooling}
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
readCodexAuthFileWebsockets,
|
||||
} from '@/features/authFiles/constants';
|
||||
|
||||
type AuthFileHeaders = Record<string, string>;
|
||||
type AuthFileHeadersErrorKey =
|
||||
| 'auth_files.headers_invalid_json'
|
||||
| 'auth_files.headers_invalid_object'
|
||||
| 'auth_files.headers_invalid_value';
|
||||
|
||||
export type PrefixProxyEditorField =
|
||||
| 'prefix'
|
||||
| 'proxyUrl'
|
||||
@@ -21,7 +27,8 @@ export type PrefixProxyEditorField =
|
||||
| 'excludedModelsText'
|
||||
| 'disableCooling'
|
||||
| 'websockets'
|
||||
| 'note';
|
||||
| 'note'
|
||||
| 'headersText';
|
||||
|
||||
export type PrefixProxyEditorFieldValue = string | boolean;
|
||||
|
||||
@@ -43,6 +50,9 @@ export type PrefixProxyEditorState = {
|
||||
websockets: boolean;
|
||||
note: string;
|
||||
noteTouched: boolean;
|
||||
headersText: string;
|
||||
headersTouched: boolean;
|
||||
headersError: string | null;
|
||||
};
|
||||
|
||||
export type UseAuthFilesPrefixProxyEditorOptions = {
|
||||
@@ -64,7 +74,45 @@ export type UseAuthFilesPrefixProxyEditorResult = {
|
||||
handlePrefixProxySave: () => Promise<void>;
|
||||
};
|
||||
|
||||
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
|
||||
const isRecordObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const validateHeadersValue = (value: unknown): AuthFileHeadersErrorKey | null => {
|
||||
if (!isRecordObject(value)) {
|
||||
return 'auth_files.headers_invalid_object';
|
||||
}
|
||||
return Object.values(value).every((item) => typeof item === 'string')
|
||||
? null
|
||||
: 'auth_files.headers_invalid_value';
|
||||
};
|
||||
|
||||
const parseHeadersText = (
|
||||
text: string
|
||||
): { value: AuthFileHeaders | null; errorKey: AuthFileHeadersErrorKey | null } => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { value: null, errorKey: null };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return { value: null, errorKey: 'auth_files.headers_invalid_json' };
|
||||
}
|
||||
|
||||
const errorKey = validateHeadersValue(parsed);
|
||||
if (errorKey) {
|
||||
return { value: null, errorKey };
|
||||
}
|
||||
|
||||
return { value: parsed as AuthFileHeaders, errorKey: null };
|
||||
};
|
||||
|
||||
const buildPrefixProxyUpdatedText = (
|
||||
editor: PrefixProxyEditorState | null,
|
||||
resolveHeadersError: (key: AuthFileHeadersErrorKey) => string
|
||||
): string => {
|
||||
if (!editor?.json) return editor?.rawText ?? '';
|
||||
const next: Record<string, unknown> = { ...editor.json };
|
||||
if ('prefix' in next || editor.prefix.trim()) {
|
||||
@@ -104,6 +152,18 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
|
||||
}
|
||||
}
|
||||
|
||||
if (editor.headersTouched) {
|
||||
const { value: parsedHeaders, errorKey } = parseHeadersText(editor.headersText);
|
||||
if (errorKey) {
|
||||
throw new Error(resolveHeadersError(errorKey));
|
||||
}
|
||||
if (parsedHeaders) {
|
||||
next.headers = parsedHeaders;
|
||||
} else {
|
||||
delete next.headers;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
editor.isCodexFile ? applyCodexAuthFileWebsockets(next, editor.websockets) : next
|
||||
);
|
||||
@@ -118,11 +178,18 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
|
||||
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
|
||||
|
||||
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
|
||||
const hasBlockingValidationError = Boolean(
|
||||
prefixProxyEditor?.headersTouched && prefixProxyEditor.headersError
|
||||
);
|
||||
const prefixProxyUpdatedText =
|
||||
prefixProxyEditor?.json && !hasBlockingValidationError
|
||||
? buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key))
|
||||
: '';
|
||||
|
||||
const prefixProxyDirty =
|
||||
Boolean(prefixProxyEditor?.json) &&
|
||||
Boolean(prefixProxyEditor?.originalText) &&
|
||||
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
|
||||
(prefixProxyUpdatedText === '' || prefixProxyUpdatedText !== prefixProxyEditor?.originalText);
|
||||
|
||||
const closePrefixProxyEditor = () => {
|
||||
setPrefixProxyEditor(null);
|
||||
@@ -162,6 +229,9 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
websockets: false,
|
||||
note: '',
|
||||
noteTouched: false,
|
||||
headersText: '',
|
||||
headersTouched: false,
|
||||
headersError: null,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -213,6 +283,14 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
|
||||
const websocketsValue = readCodexAuthFileWebsockets(json);
|
||||
const note = typeof json.note === 'string' ? json.note : '';
|
||||
const headers = json.headers;
|
||||
let headersText = '';
|
||||
let headersError: string | null = null;
|
||||
if (headers !== undefined) {
|
||||
headersText = JSON.stringify(headers, null, 2);
|
||||
const { errorKey } = parseHeadersText(headersText);
|
||||
headersError = errorKey ? t(errorKey) : null;
|
||||
}
|
||||
|
||||
setPrefixProxyEditor((prev) => {
|
||||
if (!prev || prev.fileName !== name) return prev;
|
||||
@@ -231,6 +309,9 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
websockets: websocketsValue,
|
||||
note,
|
||||
noteTouched: false,
|
||||
headersText,
|
||||
headersTouched: false,
|
||||
headersError,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
@@ -256,6 +337,16 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
|
||||
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
|
||||
if (field === 'note') return { ...prev, note: String(value), noteTouched: true };
|
||||
if (field === 'headersText') {
|
||||
const headersText = String(value);
|
||||
const { errorKey } = parseHeadersText(headersText);
|
||||
return {
|
||||
...prev,
|
||||
headersText,
|
||||
headersTouched: true,
|
||||
headersError: errorKey ? t(errorKey) : null,
|
||||
};
|
||||
}
|
||||
return { ...prev, websockets: Boolean(value) };
|
||||
});
|
||||
};
|
||||
@@ -265,7 +356,15 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
if (!prefixProxyDirty) return;
|
||||
|
||||
const name = prefixProxyEditor.fileName;
|
||||
const payload = prefixProxyUpdatedText;
|
||||
let payload = '';
|
||||
try {
|
||||
payload = buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key));
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Invalid format';
|
||||
showNotification(errorMessage, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileSize = new Blob([payload]).size;
|
||||
if (fileSize > MAX_AUTH_FILE_SIZE) {
|
||||
showNotification(
|
||||
|
||||
@@ -647,6 +647,12 @@ function getNextDirtyFields(
|
||||
nextValues.quotaSwitchPreviewModel === baselineValues.quotaSwitchPreviewModel
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'quotaAntigravityCredits')) {
|
||||
updateDirty(
|
||||
'quotaAntigravityCredits',
|
||||
nextValues.quotaAntigravityCredits === baselineValues.quotaAntigravityCredits
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'routingStrategy')) {
|
||||
updateDirty('routingStrategy', nextValues.routingStrategy === baselineValues.routingStrategy);
|
||||
}
|
||||
@@ -827,6 +833,7 @@ export function useVisualConfig() {
|
||||
|
||||
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
|
||||
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? true),
|
||||
|
||||
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
|
||||
@@ -929,11 +936,16 @@ export function useVisualConfig() {
|
||||
if (
|
||||
docHas(doc, ['quota-exceeded']) ||
|
||||
!values.quotaSwitchProject ||
|
||||
!values.quotaSwitchPreviewModel
|
||||
!values.quotaSwitchPreviewModel ||
|
||||
!values.quotaAntigravityCredits
|
||||
) {
|
||||
ensureMapInDoc(doc, ['quota-exceeded']);
|
||||
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
|
||||
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
values.quotaAntigravityCredits
|
||||
);
|
||||
deleteIfMapEmpty(doc, ['quota-exceeded']);
|
||||
}
|
||||
|
||||
|
||||
@@ -597,6 +597,12 @@
|
||||
"note_placeholder": "Enter a note, e.g.: John's account",
|
||||
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
|
||||
"note_display": "Note",
|
||||
"headers_label": "Custom Headers (headers)",
|
||||
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
|
||||
"headers_hint": "Enter custom HTTP headers as a JSON object, e.g., {\"X-My-Header\": \"value\"}",
|
||||
"headers_invalid_json": "Custom headers must be valid JSON.",
|
||||
"headers_invalid_object": "Custom headers must be a JSON object.",
|
||||
"headers_invalid_value": "Each custom header value must be a string.",
|
||||
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
|
||||
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||
@@ -664,7 +670,8 @@
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
"plan_pro": "Pro 20x",
|
||||
"plan_prolite": "Pro 5x"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI Quota",
|
||||
@@ -1023,6 +1030,15 @@
|
||||
"request_events_source": "Source",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Result",
|
||||
"time": "Latency",
|
||||
"avg_time": "Avg Latency",
|
||||
"total_time": "Total Latency",
|
||||
"latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.",
|
||||
"duration_unit_d": "d",
|
||||
"duration_unit_h": "h",
|
||||
"duration_unit_m": "m",
|
||||
"duration_unit_s": "s",
|
||||
"duration_unit_ms": "ms",
|
||||
"request_events_empty_title": "No request events",
|
||||
"request_events_empty_desc": "No request details are available for the selected time range.",
|
||||
"request_events_no_result_title": "No matching events",
|
||||
@@ -1254,7 +1270,9 @@
|
||||
"switch_project": "Switch Project",
|
||||
"switch_project_desc": "Automatically switch to another project when quota is exceeded",
|
||||
"switch_preview_model": "Switch to Preview Model",
|
||||
"switch_preview_model_desc": "Switch to preview model version when quota is exceeded"
|
||||
"switch_preview_model_desc": "Switch to preview model version when quota is exceeded",
|
||||
"antigravity_credits": "Antigravity Credits Retry",
|
||||
"antigravity_credits_desc": "Retry once with enabledCreditTypes=[\"GOOGLE_ONE_AI\"] when Antigravity returns quota_exhausted 429"
|
||||
},
|
||||
"streaming": {
|
||||
"title": "Streaming Configuration",
|
||||
|
||||
@@ -667,7 +667,8 @@
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
"plan_pro": "Pro 20x",
|
||||
"plan_prolite": "Pro 5x"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Квота Gemini CLI",
|
||||
@@ -1026,6 +1027,15 @@
|
||||
"request_events_source": "Источник",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Результат",
|
||||
"time": "Задержка",
|
||||
"avg_time": "Средняя задержка",
|
||||
"total_time": "Суммарная задержка",
|
||||
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
|
||||
"duration_unit_d": "д",
|
||||
"duration_unit_h": "ч",
|
||||
"duration_unit_m": "мин",
|
||||
"duration_unit_s": "с",
|
||||
"duration_unit_ms": "мс",
|
||||
"request_events_empty_title": "События запросов отсутствуют",
|
||||
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
|
||||
"request_events_no_result_title": "Совпадений не найдено",
|
||||
@@ -1259,7 +1269,9 @@
|
||||
"switch_project": "Переключить проект",
|
||||
"switch_project_desc": "Автоматически переходить на другой проект при превышении квоты",
|
||||
"switch_preview_model": "Переключить на preview-модель",
|
||||
"switch_preview_model_desc": "Переключаться на preview-версию модели при превышении квоты"
|
||||
"switch_preview_model_desc": "Переключаться на preview-версию модели при превышении квоты",
|
||||
"antigravity_credits": "Повтор Antigravity Credits",
|
||||
"antigravity_credits_desc": "При ответе Antigravity quota_exhausted 429 повторять запрос один раз с enabledCreditTypes=[\"GOOGLE_ONE_AI\"]"
|
||||
},
|
||||
"streaming": {
|
||||
"title": "Настройки стриминга",
|
||||
|
||||
@@ -597,6 +597,12 @@
|
||||
"note_placeholder": "输入备注信息,例如:张三的账号",
|
||||
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
|
||||
"note_display": "备注",
|
||||
"headers_label": "自定义请求头(headers)",
|
||||
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
|
||||
"headers_hint": "以 JSON 对象格式输入自定义 HTTP 请求头,例如:{\"X-My-Header\": \"value\"}",
|
||||
"headers_invalid_json": "自定义请求头必须是有效的 JSON。",
|
||||
"headers_invalid_object": "自定义请求头必须是 JSON 对象。",
|
||||
"headers_invalid_value": "每个自定义请求头的值都必须是字符串。",
|
||||
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
|
||||
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
|
||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||
@@ -664,7 +670,8 @@
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
"plan_pro": "Pro 20x",
|
||||
"plan_prolite": "Pro 5x"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI 额度",
|
||||
@@ -1023,6 +1030,15 @@
|
||||
"request_events_source": "来源",
|
||||
"request_events_auth_index": "认证索引",
|
||||
"request_events_result": "结果",
|
||||
"time": "延迟",
|
||||
"avg_time": "平均延迟",
|
||||
"total_time": "总延迟",
|
||||
"latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。",
|
||||
"duration_unit_d": "天",
|
||||
"duration_unit_h": "时",
|
||||
"duration_unit_m": "分",
|
||||
"duration_unit_s": "秒",
|
||||
"duration_unit_ms": "毫秒",
|
||||
"request_events_empty_title": "暂无请求事件",
|
||||
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
|
||||
"request_events_no_result_title": "没有匹配结果",
|
||||
@@ -1254,7 +1270,9 @@
|
||||
"switch_project": "切换项目",
|
||||
"switch_project_desc": "配额耗尽时自动切换到其他项目",
|
||||
"switch_preview_model": "切换预览模型",
|
||||
"switch_preview_model_desc": "配额耗尽时切换到预览版本模型"
|
||||
"switch_preview_model_desc": "配额耗尽时切换到预览版本模型",
|
||||
"antigravity_credits": "Antigravity Credits 重试",
|
||||
"antigravity_credits_desc": "Antigravity 返回 quota_exhausted 429 时,使用 enabledCreditTypes=[\"GOOGLE_ONE_AI\"] 重试一次"
|
||||
},
|
||||
"streaming": {
|
||||
"title": "流式传输配置",
|
||||
|
||||
@@ -164,7 +164,7 @@ export function AiProvidersPage() {
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteGeminiKey(entry.apiKey);
|
||||
await providersApi.deleteGeminiKey(entry.apiKey, entry.baseUrl);
|
||||
const next = geminiKeys.filter((_, idx) => idx !== index);
|
||||
setGeminiKeys(next);
|
||||
updateConfigValue('gemini-api-key', next);
|
||||
@@ -297,14 +297,14 @@ export function AiProvidersPage() {
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
if (type === 'codex') {
|
||||
await providersApi.deleteCodexConfig(entry.apiKey);
|
||||
await providersApi.deleteCodexConfig(entry.apiKey, entry.baseUrl);
|
||||
const next = codexConfigs.filter((_, idx) => idx !== index);
|
||||
setCodexConfigs(next);
|
||||
updateConfigValue('codex-api-key', next);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(t('notification.codex_config_deleted'), 'success');
|
||||
} else {
|
||||
await providersApi.deleteClaudeConfig(entry.apiKey);
|
||||
await providersApi.deleteClaudeConfig(entry.apiKey, entry.baseUrl);
|
||||
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
||||
setClaudeConfigs(next);
|
||||
updateConfigValue('claude-api-key', next);
|
||||
@@ -329,7 +329,7 @@ export function AiProvidersPage() {
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||
await providersApi.deleteVertexConfig(entry.apiKey, entry.baseUrl);
|
||||
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||
setVertexConfigs(next);
|
||||
updateConfigValue('vertex-api-key', next);
|
||||
|
||||
@@ -1384,6 +1384,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.prefixProxyTextareaInvalid {
|
||||
border-color: var(--danger-color);
|
||||
box-shadow: 0 0 0 3px rgba($error-color, 0.12);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -128,8 +128,7 @@
|
||||
padding: 18px;
|
||||
background:
|
||||
radial-gradient(120% 140% at 12% 0%, var(--accent-soft) 0%, rgba(0, 0, 0, 0) 62%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
|
||||
var(--bg-primary);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), var(--bg-primary);
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
@@ -137,7 +136,10 @@
|
||||
gap: 10px;
|
||||
min-height: 176px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
|
||||
transition:
|
||||
transform $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
border-color $transition-fast;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
@@ -350,7 +352,10 @@
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
@@ -505,6 +510,12 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.detailsNote {
|
||||
padding: 0 4px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// Table (80%比例)
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
@@ -515,7 +526,8 @@
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
@@ -596,6 +608,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.durationCell {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Pricing Section (80%比例)
|
||||
.pricingSection {
|
||||
display: flex;
|
||||
@@ -1095,7 +1112,9 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
|
||||
.healthBlockWrapper:hover &,
|
||||
.healthBlockWrapper.healthBlockActive & {
|
||||
|
||||
@@ -28,6 +28,13 @@ const extractArrayPayload = (data: unknown, key: string): unknown[] => {
|
||||
return Array.isArray(candidate) ? candidate : [];
|
||||
};
|
||||
|
||||
const buildProviderDeleteQuery = (apiKey: string, baseUrl?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('api-key', apiKey.trim());
|
||||
params.set('base-url', (baseUrl ?? '').trim());
|
||||
return `?${params.toString()}`;
|
||||
};
|
||||
|
||||
const serializeModelAliases = (models?: ModelAlias[]) =>
|
||||
Array.isArray(models)
|
||||
? models
|
||||
@@ -160,8 +167,8 @@ export const providersApi = {
|
||||
updateGeminiKey: (index: number, value: GeminiKeyConfig) =>
|
||||
apiClient.patch('/gemini-api-key', { index, value: serializeGeminiKey(value) }),
|
||||
|
||||
deleteGeminiKey: (apiKey: string) =>
|
||||
apiClient.delete(`/gemini-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||
deleteGeminiKey: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/gemini-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
|
||||
const data = await apiClient.get('/codex-api-key');
|
||||
@@ -175,8 +182,8 @@ export const providersApi = {
|
||||
updateCodexConfig: (index: number, value: ProviderKeyConfig) =>
|
||||
apiClient.patch('/codex-api-key', { index, value: serializeProviderKey(value) }),
|
||||
|
||||
deleteCodexConfig: (apiKey: string) =>
|
||||
apiClient.delete(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||
deleteCodexConfig: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/codex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
|
||||
const data = await apiClient.get('/claude-api-key');
|
||||
@@ -190,8 +197,8 @@ export const providersApi = {
|
||||
updateClaudeConfig: (index: number, value: ProviderKeyConfig) =>
|
||||
apiClient.patch('/claude-api-key', { index, value: serializeProviderKey(value) }),
|
||||
|
||||
deleteClaudeConfig: (apiKey: string) =>
|
||||
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||
deleteClaudeConfig: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/claude-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
|
||||
const data = await apiClient.get('/vertex-api-key');
|
||||
@@ -205,8 +212,8 @@ export const providersApi = {
|
||||
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
|
||||
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
|
||||
|
||||
deleteVertexConfig: (apiKey: string) =>
|
||||
apiClient.delete(`/vertex-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||
deleteVertexConfig: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/vertex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
|
||||
const data = await apiClient.get('/openai-compatibility');
|
||||
|
||||
@@ -366,7 +366,12 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
|
||||
if (isRecord(quota)) {
|
||||
config.quotaExceeded = {
|
||||
switchProject: normalizeBoolean(quota['switch-project'] ?? quota.switchProject),
|
||||
switchPreviewModel: normalizeBoolean(quota['switch-preview-model'] ?? quota.switchPreviewModel)
|
||||
switchPreviewModel: normalizeBoolean(
|
||||
quota['switch-preview-model'] ?? quota.switchPreviewModel
|
||||
),
|
||||
antigravityCredits: normalizeBoolean(
|
||||
quota['antigravity-credits'] ?? quota.antigravityCredits
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { AmpcodeConfig } from './ampcode';
|
||||
export interface QuotaExceededConfig {
|
||||
switchProject?: boolean;
|
||||
switchPreviewModel?: boolean;
|
||||
antigravityCredits?: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
|
||||
@@ -75,6 +75,7 @@ export type VisualConfigValues = {
|
||||
maxRetryInterval: string;
|
||||
quotaSwitchProject: boolean;
|
||||
quotaSwitchPreviewModel: boolean;
|
||||
quotaAntigravityCredits: boolean;
|
||||
routingStrategy: 'round-robin' | 'fill-first';
|
||||
wsAuth: boolean;
|
||||
payloadDefaultRules: PayloadRule[];
|
||||
@@ -114,6 +115,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
maxRetryInterval: '',
|
||||
quotaSwitchProject: true,
|
||||
quotaSwitchPreviewModel: true,
|
||||
quotaAntigravityCredits: true,
|
||||
routingStrategy: 'round-robin',
|
||||
wsAuth: false,
|
||||
payloadDefaultRules: [],
|
||||
|
||||
+227
-71
@@ -4,8 +4,25 @@
|
||||
*/
|
||||
|
||||
import type { ScriptableContext } from 'chart.js';
|
||||
import type { LatencyAccumulator, LatencyStats } from './usage/latency';
|
||||
import {
|
||||
addLatencySample,
|
||||
calculateLatencyStatsFromDetails,
|
||||
createLatencyAccumulator,
|
||||
extractLatencyMs,
|
||||
finalizeLatencyStats,
|
||||
} from './usage/latency';
|
||||
import { maskApiKey } from './format';
|
||||
|
||||
export type { DurationFormatOptions, LatencyStats } from './usage/latency';
|
||||
export {
|
||||
LATENCY_SOURCE_FIELD,
|
||||
LATENCY_SOURCE_UNIT,
|
||||
calculateLatencyStatsFromDetails,
|
||||
extractLatencyMs,
|
||||
formatDurationMs,
|
||||
} from './usage/latency';
|
||||
|
||||
export interface KeyStatBucket {
|
||||
success: number;
|
||||
failure: number;
|
||||
@@ -39,6 +56,7 @@ export interface UsageDetail {
|
||||
timestamp: string;
|
||||
source: string;
|
||||
auth_index: number;
|
||||
latency_ms?: number;
|
||||
tokens: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
@@ -66,7 +84,22 @@ export interface ApiStats {
|
||||
failureCount: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
|
||||
models: Record<
|
||||
string,
|
||||
{ requests: number; successCount: number; failureCount: number; tokens: number }
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ModelStatsSummary {
|
||||
model: string;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
averageLatencyMs: number | null;
|
||||
totalLatencyMs: number | null;
|
||||
latencySampleCount: number;
|
||||
}
|
||||
|
||||
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
|
||||
@@ -77,7 +110,7 @@ const USAGE_ENDPOINT_METHOD_REGEX = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s
|
||||
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, number> = {
|
||||
'7h': 7 * 60 * 60 * 1000,
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -100,17 +133,21 @@ const createUsageSummary = (): UsageSummary => ({
|
||||
totalRequests: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
totalTokens: 0
|
||||
totalTokens: 0,
|
||||
});
|
||||
|
||||
const toUsageSummaryFields = (summary: UsageSummary) => ({
|
||||
total_requests: summary.totalRequests,
|
||||
success_count: summary.successCount,
|
||||
failure_count: summary.failureCount,
|
||||
total_tokens: summary.totalTokens
|
||||
total_tokens: summary.totalTokens,
|
||||
});
|
||||
|
||||
export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
|
||||
export function filterUsageByTimeRange<T>(
|
||||
usageData: T,
|
||||
range: UsageTimeRange,
|
||||
nowMs: number = Date.now()
|
||||
): T {
|
||||
if (range === 'all') {
|
||||
return usageData;
|
||||
}
|
||||
@@ -180,7 +217,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
filteredModels[modelName] = {
|
||||
...modelEntry,
|
||||
...toUsageSummaryFields(modelSummary),
|
||||
details: filteredDetails
|
||||
details: filteredDetails,
|
||||
};
|
||||
hasModelData = true;
|
||||
|
||||
@@ -197,7 +234,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
filteredApis[apiName] = {
|
||||
...apiEntry,
|
||||
...toUsageSummaryFields(apiSummary),
|
||||
models: filteredModels
|
||||
models: filteredModels,
|
||||
};
|
||||
|
||||
totalSummary.totalRequests += apiSummary.totalRequests;
|
||||
@@ -209,7 +246,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
return {
|
||||
...usageRecord,
|
||||
...toUsageSummaryFields(totalSummary),
|
||||
apis: filteredApis
|
||||
apis: filteredApis,
|
||||
} as T;
|
||||
}
|
||||
|
||||
@@ -309,7 +346,8 @@ export function normalizeUsageSourceId(
|
||||
value: unknown,
|
||||
masker: (val: string) => string = maskApiKey
|
||||
): string {
|
||||
const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
|
||||
const raw =
|
||||
typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
@@ -325,7 +363,10 @@ export function normalizeUsageSourceId(
|
||||
return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`;
|
||||
}
|
||||
|
||||
export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] {
|
||||
export function buildCandidateUsageSourceIds(input: {
|
||||
apiKey?: string;
|
||||
prefix?: string;
|
||||
}): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
const prefix = input.prefix?.trim();
|
||||
@@ -335,6 +376,10 @@ export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?:
|
||||
|
||||
const apiKey = input.apiKey?.trim();
|
||||
if (apiKey) {
|
||||
// Include the normalised form first so that "non-standard" keys (e.g. short tokens,
|
||||
// keys containing '/' etc.) that are classified as text by normalizeUsageSourceId()
|
||||
// can still match usage details.
|
||||
result.push(normalizeUsageSourceId(apiKey));
|
||||
result.push(`${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(apiKey)}`);
|
||||
result.push(`${USAGE_SOURCE_PREFIX_MASKED}${maskApiKey(apiKey)}`);
|
||||
}
|
||||
@@ -345,7 +390,10 @@ export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?:
|
||||
/**
|
||||
* 对使用数据中的敏感字段进行遮罩
|
||||
*/
|
||||
export function maskUsageSensitiveValue(value: unknown, masker: (val: string) => string = maskApiKey): string {
|
||||
export function maskUsageSensitiveValue(
|
||||
value: unknown,
|
||||
masker: (val: string) => string = maskApiKey
|
||||
): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
@@ -357,12 +405,20 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) =>
|
||||
let masked = raw;
|
||||
|
||||
const queryRegex = /([?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/gi;
|
||||
masked = masked.replace(queryRegex, (_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}`);
|
||||
masked = masked.replace(
|
||||
queryRegex,
|
||||
(_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}`
|
||||
);
|
||||
|
||||
const headerRegex = /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi;
|
||||
masked = masked.replace(headerRegex, (_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}`);
|
||||
const headerRegex =
|
||||
/(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi;
|
||||
masked = masked.replace(
|
||||
headerRegex,
|
||||
(_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}`
|
||||
);
|
||||
|
||||
const keyLikeRegex = /(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g;
|
||||
const keyLikeRegex =
|
||||
/(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g;
|
||||
masked = masked.replace(keyLikeRegex, (match) => masker(match));
|
||||
|
||||
if (masked === raw) {
|
||||
@@ -436,7 +492,7 @@ export function formatUsd(value: number): string {
|
||||
const fixed = num.toFixed(2);
|
||||
const parts = Number(fixed).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return `$${parts}`;
|
||||
}
|
||||
@@ -491,10 +547,12 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||
const timestamp = detailRaw.timestamp;
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
|
||||
const latencyMs = extractLatencyMs(detailRaw);
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index: detailRaw.auth_index as unknown as number,
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
@@ -562,10 +620,12 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
|
||||
const timestamp = detailRaw.timestamp;
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
|
||||
const latencyMs = extractLatencyMs(detailRaw);
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index: detailRaw.auth_index as unknown as number,
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
@@ -605,6 +665,13 @@ export function extractTotalTokens(detail: unknown): number {
|
||||
return inputTokens + outputTokens + reasoningTokens + cachedTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算耗时统计
|
||||
*/
|
||||
export function calculateLatencyStats(usageData: unknown): LatencyStats {
|
||||
return calculateLatencyStatsFromDetails(collectUsageDetails(usageData));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 token 分类统计
|
||||
*/
|
||||
@@ -652,7 +719,9 @@ export function calculateRecentPerMinuteRates(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
@@ -666,7 +735,7 @@ export function calculateRecentPerMinuteRates(
|
||||
tpm: tokenCount / denominator,
|
||||
windowMinutes: effectiveWindow,
|
||||
requestCount,
|
||||
tokenCount
|
||||
tokenCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -694,7 +763,10 @@ export function getModelNamesFromUsage(usageData: unknown): string[] {
|
||||
/**
|
||||
* 计算成本数据
|
||||
*/
|
||||
export function calculateCost(detail: UsageDetail, modelPrices: Record<string, ModelPrice>): number {
|
||||
export function calculateCost(
|
||||
detail: UsageDetail,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): number {
|
||||
const modelName = detail.__modelName || '';
|
||||
const price = modelPrices[modelName];
|
||||
if (!price) {
|
||||
@@ -707,7 +779,9 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
|
||||
const rawCachedTokensAlternate = Number(tokens.cache_tokens);
|
||||
|
||||
const inputTokens = Number.isFinite(rawInputTokens) ? Math.max(rawInputTokens, 0) : 0;
|
||||
const completionTokens = Number.isFinite(rawCompletionTokens) ? Math.max(rawCompletionTokens, 0) : 0;
|
||||
const completionTokens = Number.isFinite(rawCompletionTokens)
|
||||
? Math.max(rawCompletionTokens, 0)
|
||||
: 0;
|
||||
const cachedTokens = Math.max(
|
||||
Number.isFinite(rawCachedTokensPrimary) ? Math.max(rawCachedTokensPrimary, 0) : 0,
|
||||
Number.isFinite(rawCachedTokensAlternate) ? Math.max(rawCachedTokensAlternate, 0) : 0
|
||||
@@ -716,7 +790,8 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
|
||||
|
||||
const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0);
|
||||
const cachedCost = (cachedTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.cache) || 0);
|
||||
const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||||
const completionCost =
|
||||
(completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||||
const total = promptCost + cachedCost + completionCost;
|
||||
return Number.isFinite(total) && total > 0 ? total : 0;
|
||||
}
|
||||
@@ -724,7 +799,10 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
|
||||
/**
|
||||
* 计算总成本
|
||||
*/
|
||||
export function calculateTotalCost(usageData: unknown, modelPrices: Record<string, ModelPrice>): number {
|
||||
export function calculateTotalCost(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): number {
|
||||
const details = collectUsageDetails(usageData);
|
||||
if (!details.length || !Object.keys(modelPrices).length) {
|
||||
return 0;
|
||||
@@ -756,7 +834,11 @@ export function loadModelPrices(): Record<string, ModelPrice> {
|
||||
const completionRaw = Number(priceRecord?.completion);
|
||||
const cacheRaw = Number(priceRecord?.cache);
|
||||
|
||||
if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) {
|
||||
if (
|
||||
!Number.isFinite(promptRaw) &&
|
||||
!Number.isFinite(completionRaw) &&
|
||||
!Number.isFinite(cacheRaw)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -772,7 +854,7 @@ export function loadModelPrices(): Record<string, ModelPrice> {
|
||||
normalized[model] = {
|
||||
prompt,
|
||||
completion,
|
||||
cache
|
||||
cache,
|
||||
};
|
||||
});
|
||||
return normalized;
|
||||
@@ -798,14 +880,20 @@ export function saveModelPrices(prices: Record<string, ModelPrice>): void {
|
||||
/**
|
||||
* 获取 API 统计数据
|
||||
*/
|
||||
export function getApiStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): ApiStats[] {
|
||||
export function getApiStats(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): ApiStats[] {
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!apis) return [];
|
||||
const result: ApiStats[] = [];
|
||||
|
||||
Object.entries(apis).forEach(([endpoint, apiData]) => {
|
||||
if (!isRecord(apiData)) return;
|
||||
const models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }> = {};
|
||||
const models: Record<
|
||||
string,
|
||||
{ requests: number; successCount: number; failureCount: number; tokens: number }
|
||||
> = {};
|
||||
let derivedSuccessCount = 0;
|
||||
let derivedFailureCount = 0;
|
||||
let totalCost = 0;
|
||||
@@ -849,7 +937,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
|
||||
requests: Number(modelData.total_requests) || 0,
|
||||
successCount,
|
||||
failureCount,
|
||||
tokens: Number(modelData.total_tokens) || 0
|
||||
tokens: Number(modelData.total_tokens) || 0,
|
||||
};
|
||||
derivedSuccessCount += successCount;
|
||||
derivedFailureCount += failureCount;
|
||||
@@ -858,10 +946,10 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
|
||||
const hasApiExplicitCounts =
|
||||
typeof apiData.success_count === 'number' || typeof apiData.failure_count === 'number';
|
||||
const successCount = hasApiExplicitCounts
|
||||
? (Number(apiData.success_count) || 0)
|
||||
? Number(apiData.success_count) || 0
|
||||
: derivedSuccessCount;
|
||||
const failureCount = hasApiExplicitCounts
|
||||
? (Number(apiData.failure_count) || 0)
|
||||
? Number(apiData.failure_count) || 0
|
||||
: derivedFailureCount;
|
||||
|
||||
result.push({
|
||||
@@ -871,7 +959,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
|
||||
failureCount,
|
||||
totalTokens: Number(apiData.total_tokens) || 0,
|
||||
totalCost,
|
||||
models
|
||||
models,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -881,18 +969,24 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
|
||||
/**
|
||||
* 获取模型统计数据
|
||||
*/
|
||||
export function getModelStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): Array<{
|
||||
model: string;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}> {
|
||||
export function getModelStats(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): ModelStatsSummary[] {
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!apis) return [];
|
||||
|
||||
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
|
||||
const modelMap = new Map<
|
||||
string,
|
||||
{
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
latency: LatencyAccumulator;
|
||||
}
|
||||
>();
|
||||
|
||||
Object.values(apis).forEach((apiData) => {
|
||||
if (!isRecord(apiData)) return;
|
||||
@@ -902,7 +996,14 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
|
||||
|
||||
Object.entries(models).forEach(([modelName, modelData]) => {
|
||||
if (!isRecord(modelData)) return;
|
||||
const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
|
||||
const existing = modelMap.get(modelName) || {
|
||||
requests: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
latency: createLatencyAccumulator(),
|
||||
};
|
||||
existing.requests += Number(modelData.total_requests) || 0;
|
||||
existing.tokens += Number(modelData.total_tokens) || 0;
|
||||
|
||||
@@ -917,9 +1018,10 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
|
||||
existing.failureCount += Number(modelData.failure_count) || 0;
|
||||
}
|
||||
|
||||
if (details.length > 0 && (!hasExplicitCounts || price)) {
|
||||
if (details.length > 0) {
|
||||
details.forEach((detail) => {
|
||||
const detailRecord = isRecord(detail) ? detail : null;
|
||||
const latencyMs = extractLatencyMs(detailRecord);
|
||||
if (!hasExplicitCounts) {
|
||||
if (detailRecord?.failed === true) {
|
||||
existing.failureCount += 1;
|
||||
@@ -928,6 +1030,8 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
|
||||
}
|
||||
}
|
||||
|
||||
addLatencySample(existing.latency, latencyMs);
|
||||
|
||||
if (price && detailRecord) {
|
||||
existing.cost += calculateCost(
|
||||
{ ...(detailRecord as unknown as UsageDetail), __modelName: modelName },
|
||||
@@ -941,7 +1045,20 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
|
||||
});
|
||||
|
||||
return Array.from(modelMap.entries())
|
||||
.map(([model, stats]) => ({ model, ...stats }))
|
||||
.map(([model, stats]) => {
|
||||
const latencyStats = finalizeLatencyStats(stats.latency);
|
||||
return {
|
||||
model,
|
||||
requests: stats.requests,
|
||||
successCount: stats.successCount,
|
||||
failureCount: stats.failureCount,
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
averageLatencyMs: latencyStats.averageMs,
|
||||
totalLatencyMs: latencyStats.totalMs,
|
||||
latencySampleCount: latencyStats.sampleCount,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.requests - a.requests);
|
||||
}
|
||||
|
||||
@@ -1012,7 +1129,9 @@ export function buildHourlySeriesByModel(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1069,7 +1188,9 @@ export function buildDailySeriesByModel(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1092,7 +1213,7 @@ export function buildDailySeriesByModel(
|
||||
const labels = Array.from(labelsSet).sort();
|
||||
const dataByModel = new Map<string, number[]>();
|
||||
valuesByModel.forEach((dayMap, modelName) => {
|
||||
const series = labels.map(label => dayMap.get(label) || 0);
|
||||
const series = labels.map((label) => dayMap.get(label) || 0);
|
||||
dataByModel.set(modelName, series);
|
||||
});
|
||||
|
||||
@@ -1103,7 +1224,10 @@ export interface ChartDataset {
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor: string;
|
||||
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
|
||||
backgroundColor:
|
||||
| string
|
||||
| CanvasGradient
|
||||
| ((context: ScriptableContext<'line'>) => string | CanvasGradient);
|
||||
pointBackgroundColor?: string;
|
||||
pointBorderColor?: string;
|
||||
fill: boolean;
|
||||
@@ -1152,7 +1276,11 @@ const withAlpha = (hex: string, alpha: number) => {
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`;
|
||||
};
|
||||
|
||||
const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string, fallback: string) => {
|
||||
const buildAreaGradient = (
|
||||
context: ScriptableContext<'line'>,
|
||||
baseHex: string,
|
||||
fallback: string
|
||||
) => {
|
||||
const chart = context.chart;
|
||||
const ctx = chart.ctx;
|
||||
const area = chart.chartArea;
|
||||
@@ -1178,16 +1306,17 @@ export function buildChartData(
|
||||
selectedModels: string[] = [],
|
||||
options: { hourWindowHours?: number } = {}
|
||||
): ChartData {
|
||||
const baseSeries = period === 'hour'
|
||||
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
|
||||
: buildDailySeriesByModel(usageData, metric);
|
||||
const baseSeries =
|
||||
period === 'hour'
|
||||
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
|
||||
: buildDailySeriesByModel(usageData, metric);
|
||||
|
||||
const { labels, dataByModel } = baseSeries;
|
||||
|
||||
// Build "All" series as sum of all models
|
||||
const getAllSeries = (): number[] => {
|
||||
const summed = new Array(labels.length).fill(0);
|
||||
dataByModel.forEach(values => {
|
||||
dataByModel.forEach((values) => {
|
||||
values.forEach((value, idx) => {
|
||||
summed[idx] = (summed[idx] || 0) + value;
|
||||
});
|
||||
@@ -1200,7 +1329,9 @@ export function buildChartData(
|
||||
|
||||
const datasets: ChartDataset[] = modelsToShow.map((model, index) => {
|
||||
const isAll = model === 'all';
|
||||
const data = isAll ? getAllSeries() : (dataByModel.get(model) || new Array(labels.length).fill(0));
|
||||
const data = isAll
|
||||
? getAllSeries()
|
||||
: dataByModel.get(model) || new Array(labels.length).fill(0);
|
||||
const colorIndex = index % CHART_COLORS.length;
|
||||
const style = CHART_COLORS[colorIndex];
|
||||
const shouldFill = modelsToShow.length === 1 || (isAll && modelsToShow.length > 1);
|
||||
@@ -1215,7 +1346,7 @@ export function buildChartData(
|
||||
pointBackgroundColor: style.borderColor,
|
||||
pointBorderColor: style.borderColor,
|
||||
fill: shouldFill,
|
||||
tension: 0.35
|
||||
tension: 0.35,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1283,8 +1414,15 @@ export function calculateStatusBarData(
|
||||
// Filter and bucket the usage details
|
||||
usageDetails.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp <= 0 ||
|
||||
timestamp < windowStart ||
|
||||
timestamp > now
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1346,7 +1484,7 @@ export function calculateStatusBarData(
|
||||
blockDetails,
|
||||
successRate,
|
||||
totalSuccess,
|
||||
totalFailure
|
||||
totalFailure,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1364,9 +1502,7 @@ export interface ServiceHealthData {
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export function calculateServiceHealthData(
|
||||
usageDetails: UsageDetail[]
|
||||
): ServiceHealthData {
|
||||
export function calculateServiceHealthData(usageDetails: UsageDetail[]): ServiceHealthData {
|
||||
const ROWS = 7;
|
||||
const COLS = 96;
|
||||
const BLOCK_COUNT = ROWS * COLS; // 672
|
||||
@@ -1386,8 +1522,15 @@ export function calculateServiceHealthData(
|
||||
|
||||
usageDetails.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp <= 0 ||
|
||||
timestamp < windowStart ||
|
||||
timestamp > now
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1444,7 +1587,10 @@ export function calculateServiceHealthData(
|
||||
};
|
||||
}
|
||||
|
||||
export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats {
|
||||
export function computeKeyStats(
|
||||
usageData: unknown,
|
||||
masker: (val: string) => string = maskApiKey
|
||||
): KeyStats {
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!apis) {
|
||||
return { bySource: {}, byAuthIndex: {} };
|
||||
@@ -1499,7 +1645,7 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
|
||||
|
||||
return {
|
||||
bySource: sourceStats,
|
||||
byAuthIndex: authIndexStats
|
||||
byAuthIndex: authIndexStats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1586,7 +1732,9 @@ export function buildHourlyTokenBreakdown(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
@@ -1601,9 +1749,10 @@ export function buildHourlyTokenBreakdown(
|
||||
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
|
||||
const cached = Math.max(
|
||||
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
|
||||
);
|
||||
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
const reasoning =
|
||||
typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
|
||||
dataByCategory.input[bucketIndex] += input;
|
||||
dataByCategory.output[bucketIndex] += output;
|
||||
@@ -1625,7 +1774,9 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
@@ -1639,9 +1790,10 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
|
||||
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
|
||||
const cached = Math.max(
|
||||
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
|
||||
);
|
||||
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
const reasoning =
|
||||
typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
|
||||
dayMap[dayLabel].input += input;
|
||||
dayMap[dayLabel].output += output;
|
||||
@@ -1699,7 +1851,9 @@ export function buildHourlyCostSeries(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
@@ -1732,7 +1886,9 @@ export function buildDailyCostSeries(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export const LATENCY_SOURCE_FIELD = 'latency_ms';
|
||||
export const LATENCY_SOURCE_UNIT = 'ms';
|
||||
|
||||
export interface LatencyStats {
|
||||
averageMs: number | null;
|
||||
totalMs: number | null;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
export interface DurationFormatOptions {
|
||||
maxUnits?: number;
|
||||
invalidText?: string;
|
||||
secondDecimals?: number | 'auto';
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface LatencyAccumulator {
|
||||
totalMs: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const normalizeDurationMaxUnits = (value: number | undefined): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 2;
|
||||
}
|
||||
return Math.min(Math.floor(parsed), 4);
|
||||
};
|
||||
|
||||
const resolveSecondDecimalPlaces = (
|
||||
seconds: number,
|
||||
secondDecimals: number | 'auto' | undefined
|
||||
): number => {
|
||||
if (secondDecimals === 'auto' || secondDecimals === undefined) {
|
||||
return seconds < 10 ? 2 : 1;
|
||||
}
|
||||
|
||||
const parsed = Math.floor(Number(secondDecimals));
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return seconds < 10 ? 2 : 1;
|
||||
}
|
||||
return Math.min(parsed, 3);
|
||||
};
|
||||
|
||||
const resolveDurationLocale = (locale?: string): string | undefined =>
|
||||
locale?.trim() || i18n.resolvedLanguage || i18n.language || undefined;
|
||||
|
||||
const formatDurationNumber = (
|
||||
value: number,
|
||||
locale: string | undefined,
|
||||
options: Intl.NumberFormatOptions = {}
|
||||
): string => {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
useGrouping: false,
|
||||
...options,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const getDurationUnitLabel = (unit: 'd' | 'h' | 'm' | 's' | 'ms'): string =>
|
||||
i18n.t(`usage_stats.duration_unit_${unit}`, { defaultValue: unit });
|
||||
|
||||
const formatDurationPart = (
|
||||
value: number,
|
||||
unit: 'd' | 'h' | 'm' | 's' | 'ms',
|
||||
locale: string | undefined,
|
||||
options: Intl.NumberFormatOptions = {}
|
||||
): string => `${formatDurationNumber(value, locale, options)}${getDurationUnitLabel(unit)}`;
|
||||
|
||||
/**
|
||||
* 从后端字段 latency_ms 提取耗时,并按毫秒解释。
|
||||
*/
|
||||
export function extractLatencyMs(detail: unknown): number | null {
|
||||
const record = isRecord(detail) ? detail : null;
|
||||
const rawValue = record?.[LATENCY_SOURCE_FIELD];
|
||||
if (
|
||||
rawValue === null ||
|
||||
rawValue === undefined ||
|
||||
(typeof rawValue === 'string' && rawValue.trim() === '')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const createLatencyAccumulator = (): LatencyAccumulator => ({
|
||||
totalMs: 0,
|
||||
sampleCount: 0,
|
||||
});
|
||||
|
||||
export const addLatencySample = (
|
||||
accumulator: LatencyAccumulator,
|
||||
latencyMs: number | null | undefined
|
||||
): void => {
|
||||
if (latencyMs === null || latencyMs === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(latencyMs);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
accumulator.totalMs += parsed;
|
||||
accumulator.sampleCount += 1;
|
||||
};
|
||||
|
||||
export const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({
|
||||
averageMs: accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null,
|
||||
totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null,
|
||||
sampleCount: accumulator.sampleCount,
|
||||
});
|
||||
|
||||
/**
|
||||
* 从明细列表计算耗时统计
|
||||
*/
|
||||
export function calculateLatencyStatsFromDetails(details: Iterable<unknown>): LatencyStats {
|
||||
const accumulator = createLatencyAccumulator();
|
||||
for (const detail of details) {
|
||||
addLatencySample(accumulator, extractLatencyMs(detail));
|
||||
}
|
||||
return finalizeLatencyStats(accumulator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前语言格式化耗时显示。
|
||||
*/
|
||||
export function formatDurationMs(
|
||||
value: number | null | undefined,
|
||||
options: DurationFormatOptions = {}
|
||||
): string {
|
||||
const invalidText = options.invalidText ?? '--';
|
||||
if (value === null || value === undefined) {
|
||||
return invalidText;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return invalidText;
|
||||
}
|
||||
|
||||
const locale = resolveDurationLocale(options.locale);
|
||||
|
||||
if (parsed < 1000) {
|
||||
return formatDurationPart(Math.round(parsed), 'ms', locale);
|
||||
}
|
||||
|
||||
const seconds = parsed / 1000;
|
||||
if (seconds < 60) {
|
||||
const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals);
|
||||
return formatDurationPart(seconds, 's', locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: secondDecimalPlaces,
|
||||
});
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
let remainingSeconds = totalSeconds;
|
||||
const days = Math.floor(remainingSeconds / 86_400);
|
||||
remainingSeconds -= days * 86_400;
|
||||
const hours = Math.floor(remainingSeconds / 3_600);
|
||||
remainingSeconds -= hours * 3_600;
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
remainingSeconds -= minutes * 60;
|
||||
|
||||
const parts = [
|
||||
{ unit: 'd' as const, value: days },
|
||||
{ unit: 'h' as const, value: hours },
|
||||
{ unit: 'm' as const, value: minutes },
|
||||
{ unit: 's' as const, value: remainingSeconds },
|
||||
].filter((part) => part.value > 0);
|
||||
|
||||
if (!parts.length) {
|
||||
return formatDurationPart(0, 's', locale);
|
||||
}
|
||||
|
||||
return parts
|
||||
.slice(0, normalizeDurationMaxUnits(options.maxUnits))
|
||||
.map((part, index) =>
|
||||
formatDurationPart(part.value, part.unit, locale, {
|
||||
minimumIntegerDigits: index > 0 && (part.unit === 'm' || part.unit === 's') ? 2 : 1,
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user