feat: improve request pricing, model diagnostics, and client discovery
- Align response model handling with CPA-Manager-Plus and flag routing mismatches - Add per-request costs and CSV export; fix mixed-context cost aggregation - Display API-reported access types and move providers next to timestamps - Use white table backgrounds and simplify cost and status displays - Expand client discovery paths, validate executables, and prioritize installed clients - Add model, pricing, client discovery, and UI regression tests
This commit is contained in:
1 parent
0e7437493d
commit
dfba9b019f
20 files changed
+652
-152
No files matched your search
+11
-3
@@ -731,8 +731,16 @@ export const jaOverrides = {
|
||||
'usage.column.time': '日時',
|
||||
'usage.column.model': 'モデル',
|
||||
'usage.model.request': 'リクエストモデル',
|
||||
'usage.model.upstream': '上流モデル',
|
||||
'usage.model.response': '上流レスポンス',
|
||||
'usage.model.upstream': 'ルーティングモデル',
|
||||
'usage.model.response': '応答モデル',
|
||||
'usage.column.cost': '今回の費用',
|
||||
'usage.cost.estimate': '推定 · USD',
|
||||
'usage.cost.unpriced': '価格未設定',
|
||||
'usage.cost.hint': '現在の価格設定による USD 推定額。料金モデル:{model}。入力、出力、キャッシュ読み書き、サービス階層と長文ルールを含みます。実際の請求額ではありません。',
|
||||
'usage.model.mismatch': 'モデル不一致',
|
||||
'usage.model.responseHint': '使用量 API の response_model(上流が申告したモデル名であり、モデルの実体を検証したものではありません)。',
|
||||
'usage.provider.hint': '使用量 API の provider と auth_type に基づく接続先と認証方式です。モデル名から提供元を推測しません。',
|
||||
'usage.provider.unknownAccess': '接続方式の記録なし',
|
||||
'usage.column.effort': '推論レベル',
|
||||
'usage.column.request': 'リクエスト',
|
||||
'usage.column.tokens': 'Token',
|
||||
@@ -1089,7 +1097,7 @@ export const jaOverrides = {
|
||||
'agents.clients.page': '{page} / {total} ページ',
|
||||
'agents.clients.listed': 'クライアント {count} 件',
|
||||
'agents.clients.manage': 'クライアントを管理',
|
||||
'agents.clients.description': '一覧に残すクライアントを選択して保存します。順序は固定で、収まらない場合はページに分けて表示します。クライアントをクリックすると設定が開きます。',
|
||||
'agents.clients.description': '一覧に残すクライアントを選択して保存します。インストール済みを先に表示し、各グループ内の順序は固定です。収まらない場合はページに分けて表示します。クリックすると設定が開きます。',
|
||||
'agents.clients.search': 'クライアントを検索',
|
||||
'agents.clients.selected': '{count} 件のクライアントを選択中',
|
||||
'agents.clients.automatic': '自動表示に戻す',
|
||||
|
||||
+11
-3
@@ -719,8 +719,16 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.column.time': 'Time',
|
||||
'usage.column.model': 'Model',
|
||||
'usage.model.request': 'Requested model',
|
||||
'usage.model.upstream': 'Upstream model',
|
||||
'usage.model.response': 'Upstream response',
|
||||
'usage.model.upstream': 'Routed model',
|
||||
'usage.model.response': 'Response model',
|
||||
'usage.column.cost': 'Request cost',
|
||||
'usage.cost.estimate': 'Estimated · USD',
|
||||
'usage.cost.unpriced': 'Unpriced',
|
||||
'usage.cost.hint': 'Estimated USD using current pricing. Pricing model: {model}; includes input, output, cache reads and writes, service tier and long-context rules. Not an invoice.',
|
||||
'usage.model.mismatch': 'Model mismatch',
|
||||
'usage.model.responseHint': 'From usage API response_model: the model name declared upstream, not a verified model identity.',
|
||||
'usage.provider.hint': 'From usage API provider and auth_type: the route and authentication method, not a vendor inferred from the model name.',
|
||||
'usage.provider.unknownAccess': 'Access type unrecorded',
|
||||
'usage.column.effort': 'Reasoning Effort',
|
||||
'usage.column.request': 'Request',
|
||||
'usage.column.tokens': 'Token',
|
||||
@@ -1089,7 +1097,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.clients.page': 'Page {page} / {total}',
|
||||
'agents.clients.listed': '{count} clients',
|
||||
'agents.clients.manage': 'Manage Clients',
|
||||
'agents.clients.description': 'Choose the clients to keep in the list, then save. Clients stay in a fixed order across pages. Click a client to open its settings.',
|
||||
'agents.clients.description': 'Choose the clients to keep in the list, then save. Installed clients come first, with a fixed order within each group across pages. Click a client to open its settings.',
|
||||
'agents.clients.search': 'Search clients',
|
||||
'agents.clients.selected': '{count} clients selected',
|
||||
'agents.clients.automatic': 'Restore automatic display',
|
||||
|
||||
@@ -718,8 +718,16 @@ export const zhCN = {
|
||||
'usage.column.time': '时间',
|
||||
'usage.column.model': '模型',
|
||||
'usage.model.request': '请求模型',
|
||||
'usage.model.upstream': '上游模型',
|
||||
'usage.model.response': '上游响应',
|
||||
'usage.model.upstream': '路由模型',
|
||||
'usage.model.response': '响应模型',
|
||||
'usage.column.cost': '本次费用',
|
||||
'usage.cost.estimate': '估算 · USD',
|
||||
'usage.cost.unpriced': '未定价',
|
||||
'usage.cost.hint': '按当前价格统计配置估算(USD)。计价模型:{model};包含输入、输出、缓存读取及写入,应用服务层级与长上下文规则,非实际账单。',
|
||||
'usage.model.mismatch': '模型不一致',
|
||||
'usage.model.responseHint': '来自用量 API 的 response_model(上游声明的模型名,不代表已验证模型身份)。',
|
||||
'usage.provider.hint': '来自用量 API 的 provider 与 auth_type,表示接入通道和认证方式,不按模型名推断厂商。',
|
||||
'usage.provider.unknownAccess': '未记录接入方式',
|
||||
'usage.column.effort': '推理强度',
|
||||
'usage.column.request': '请求',
|
||||
'usage.column.tokens': 'Token',
|
||||
@@ -1088,7 +1096,7 @@ export const zhCN = {
|
||||
'agents.clients.page': '第 {page} / {total} 页',
|
||||
'agents.clients.listed': '{count} 个客户端',
|
||||
'agents.clients.manage': '管理客户端',
|
||||
'agents.clients.description': '勾选要保留在列表中的客户端,保存后生效。列表顺序固定,放不下时分页显示;点击客户端切换配置。',
|
||||
'agents.clients.description': '勾选要保留在列表中的客户端,保存后生效。已安装的客户端优先,同组内顺序固定,放不下时分页显示;点击客户端切换配置。',
|
||||
'agents.clients.search': '搜索客户端',
|
||||
'agents.clients.selected': '已选择 {count} 个客户端',
|
||||
'agents.clients.automatic': '恢复自动显示',
|
||||
|
||||
@@ -47,10 +47,12 @@ export function AgentClientList<Id extends string>({
|
||||
const close = () => setManaging(false);
|
||||
const [draftIds, setDraftIds] = useState<Id[] | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
// Stable sorting keeps the existing order within each installation group.
|
||||
const orderedClients = [...clients].sort((left, right) => Number(right.installed) - Number(left.installed));
|
||||
// Keep the current client reachable even when detection fails or it was uninstalled.
|
||||
const automaticIds = clients.filter((client) => client.detected || client.id === selected)
|
||||
const automaticIds = orderedClients.filter((client) => client.detected || client.id === selected)
|
||||
.map((client) => client.id);
|
||||
const visibleClients = clients.filter((client) => (visibleIds ?? automaticIds).includes(client.id));
|
||||
const visibleClients = orderedClients.filter((client) => (visibleIds ?? automaticIds).includes(client.id));
|
||||
const pageCount = Math.max(1, Math.ceil(visibleClients.length / capacity));
|
||||
const currentPage = Math.min(page, pageCount - 1);
|
||||
const pageClients = visibleClients.slice(currentPage * capacity, (currentPage + 1) * capacity);
|
||||
@@ -101,13 +103,13 @@ export function AgentClientList<Id extends string>({
|
||||
}
|
||||
setVisibleIds(draftIds);
|
||||
if (!draftSelection.includes(selected)) {
|
||||
const first = clients.find((client) => draftSelection.includes(client.id));
|
||||
const first = orderedClients.find((client) => draftSelection.includes(client.id));
|
||||
if (first) onSelect(first.id);
|
||||
}
|
||||
setManaging(false);
|
||||
};
|
||||
|
||||
const matches = clients.filter((client) => client.name.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
const matches = orderedClients.filter((client) => client.name.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
|
||||
return <>
|
||||
<aside className="panel agent-client-list" aria-label={t('agents.localClients')}>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
/* The request log is deliberately scoped away from the other usage tabs. */
|
||||
.usage-records-page .usage-request-log {
|
||||
--usage-log-surface: var(--bg-card);
|
||||
--usage-log-text: #292724;
|
||||
--usage-log-muted: #77736d;
|
||||
--usage-log-line: #e5e4e2;
|
||||
--usage-log-hover: #fafaf9;
|
||||
--usage-log-soft: #f7f7f6;
|
||||
--usage-log-input: #0076b5;
|
||||
--usage-log-output: #15803d;
|
||||
--usage-log-reasoning: #7c3aed;
|
||||
--usage-log-cache-read: #b45309;
|
||||
--usage-log-cache-write: #be185d;
|
||||
--usage-log-response-model: #c2410c;
|
||||
--usage-log-surface: #fff;
|
||||
--usage-log-text: #24343b;
|
||||
--usage-log-muted: #63767d;
|
||||
--usage-log-line: #e8e8e8;
|
||||
--usage-log-hover: #f7f7f7;
|
||||
--usage-log-soft: #f5f5f5;
|
||||
--usage-log-input: #526b76;
|
||||
--usage-log-output: #16756a;
|
||||
--usage-log-reasoning: #526b76;
|
||||
--usage-log-cache-read: #16756a;
|
||||
--usage-log-cache-write: #63767d;
|
||||
--usage-log-response-model: #9a5808;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
border: 1px solid var(--usage-log-line);
|
||||
border-radius: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--usage-log-surface);
|
||||
color: var(--usage-log-text);
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 2%);
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--usage-log-line);
|
||||
border-radius: 999px;
|
||||
border-radius: 6px;
|
||||
background: var(--usage-log-surface);
|
||||
color: var(--usage-log-muted);
|
||||
box-shadow: none;
|
||||
@@ -103,17 +103,17 @@
|
||||
}
|
||||
|
||||
.usage-request-log .usage-events-table th {
|
||||
height: 36px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
background: var(--usage-log-surface);
|
||||
color: var(--usage-log-muted);
|
||||
box-shadow: none;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-request-log .usage-events-table th .usage-th-content {
|
||||
height: 36px;
|
||||
height: 40px;
|
||||
justify-content: flex-start;
|
||||
padding: 0 8px;
|
||||
text-align: left;
|
||||
@@ -122,6 +122,7 @@
|
||||
.usage-request-log .usage-events-table td {
|
||||
height: 68px;
|
||||
padding: 9px 8px;
|
||||
background: var(--usage-log-surface);
|
||||
color: var(--usage-log-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -130,6 +131,21 @@
|
||||
background: var(--usage-log-hover) !important;
|
||||
}
|
||||
|
||||
.usage-request-log .usage-th-total,
|
||||
.usage-request-log .usage-td-total {
|
||||
border-left: 1px solid var(--usage-log-line);
|
||||
}
|
||||
|
||||
.usage-request-log .usage-access-type {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--usage-log-line);
|
||||
border-radius: 3px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
.usage-request-log .usage-events-table td strong {
|
||||
color: var(--usage-log-text);
|
||||
font-weight: 600;
|
||||
@@ -149,6 +165,21 @@
|
||||
color: var(--usage-log-response-model);
|
||||
}
|
||||
|
||||
.usage-request-log .usage-model-mismatch {
|
||||
display: inline-block;
|
||||
margin-top: 3px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--usage-log-response-model) 30%, transparent);
|
||||
border-radius: 3px;
|
||||
color: var(--usage-log-response-model);
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.usage-request-log .usage-td-cost strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.usage-request-log .usage-events-table .usage-td-key > strong {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 11px;
|
||||
@@ -175,7 +206,8 @@
|
||||
gap: 9px;
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-family-mono);
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -199,43 +231,41 @@
|
||||
.usage-request-log .tone-cache-read { color: var(--usage-log-cache-read); }
|
||||
.usage-request-log .tone-cache-write { color: var(--usage-log-cache-write); }
|
||||
|
||||
.usage-request-log .usage-tag-pill {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--usage-log-text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.usage-request-log .usage-result {
|
||||
min-width: 48px;
|
||||
justify-content: center;
|
||||
padding: 3px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.usage-request-log .usage-result-dot { display: none; }
|
||||
.usage-request-log .usage-result-dot {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 1px;
|
||||
background: currentColor;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.usage-request-log .usage-result.success {
|
||||
border-color: #6ee7b7;
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
background: transparent;
|
||||
color: #16756a;
|
||||
}
|
||||
|
||||
.usage-request-log .usage-result.failed {
|
||||
border-color: var(--failure-badge-border);
|
||||
background: var(--failure-badge-bg);
|
||||
background: transparent;
|
||||
color: var(--failure-badge-text);
|
||||
}
|
||||
|
||||
.usage-request-log .usage-result.canceled {
|
||||
border-color: var(--usage-log-line);
|
||||
background: var(--usage-log-soft);
|
||||
background: transparent;
|
||||
color: var(--usage-log-muted);
|
||||
}
|
||||
|
||||
@@ -282,13 +312,13 @@
|
||||
|
||||
.usage-request-log .usage-events-footer {
|
||||
display: flex;
|
||||
min-height: 62px;
|
||||
min-height: 56px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--usage-log-line);
|
||||
background: var(--usage-log-surface);
|
||||
}
|
||||
@@ -306,7 +336,7 @@
|
||||
height: 32px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--usage-log-line);
|
||||
border-radius: 999px;
|
||||
border-radius: 6px;
|
||||
background: var(--usage-log-surface);
|
||||
color: var(--usage-log-text);
|
||||
font-size: 11px;
|
||||
@@ -324,17 +354,18 @@
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .usage-records-page .usage-request-log {
|
||||
--usage-log-surface: var(--bg-card);
|
||||
--usage-log-text: #edece9;
|
||||
--usage-log-muted: #aaa8a4;
|
||||
--usage-log-line: #34363b;
|
||||
--usage-log-hover: #26282c;
|
||||
--usage-log-soft: #292b30;
|
||||
--usage-log-input: #38bdf8;
|
||||
--usage-log-output: #4ade80;
|
||||
--usage-log-reasoning: #b69cff;
|
||||
--usage-log-cache-read: #fbbf24;
|
||||
--usage-log-cache-write: #f472b6;
|
||||
--usage-log-response-model: #fb923c;
|
||||
--usage-log-input: #adc0ca;
|
||||
--usage-log-output: #77cbbb;
|
||||
--usage-log-reasoning: #adc0ca;
|
||||
--usage-log-cache-read: #77cbbb;
|
||||
--usage-log-cache-write: #aaa8a4;
|
||||
--usage-log-response-model: #f0bc73;
|
||||
border-color: var(--usage-log-line);
|
||||
background: var(--usage-log-surface);
|
||||
box-shadow: none;
|
||||
@@ -342,7 +373,7 @@
|
||||
|
||||
:root[data-theme='dark'] .usage-request-log .usage-result.success {
|
||||
border-color: #17664e;
|
||||
background: #123c30;
|
||||
background: transparent;
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { MessageKey } from '../i18n/resources';
|
||||
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
|
||||
import { formatDuration, formatUsageNumber } from '../services/usageNumber';
|
||||
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
|
||||
import { usageProviderDetails } from '../services/usageProvider';
|
||||
import { usageModelDetails } from '../services/usageModel';
|
||||
|
||||
const compactNumber = (value: number) => formatUsageNumber(value, getCurrentLocale());
|
||||
const compactDuration = (value: number) => formatDuration(value, getCurrentLocale());
|
||||
@@ -29,9 +31,11 @@ export type UsageRecord = {
|
||||
failure_status: number;
|
||||
failure_body: string;
|
||||
provider: string;
|
||||
auth_type?: string;
|
||||
model: string;
|
||||
/** Model name reported by the upstream response, when available. */
|
||||
response_model?: string;
|
||||
cost?: { total: number; pricing_model: string } | null;
|
||||
alias: string;
|
||||
reasoning_effort: string;
|
||||
endpoint: string;
|
||||
@@ -63,6 +67,7 @@ type EventColumnKey =
|
||||
| 'source'
|
||||
| 'key'
|
||||
| 'cache'
|
||||
| 'cost'
|
||||
| 'total'
|
||||
| 'result'
|
||||
| 'latency'
|
||||
@@ -80,6 +85,7 @@ type EventColumnDef = {
|
||||
|
||||
const EVENT_COLUMNS: readonly EventColumnDef[] = [
|
||||
{ key: 'time', labelKey: 'usage.column.time', defaultWidth: 84, minWidth: 76, align: 'left' },
|
||||
{ key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 108, minWidth: 88, align: 'left' },
|
||||
{ key: 'key', labelKey: 'usage.column.key', defaultWidth: 120, minWidth: 96, align: 'left' },
|
||||
{ key: 'source', labelKey: 'usage.column.source', defaultWidth: 180, minWidth: 120, align: 'left' },
|
||||
{ key: 'model', labelKey: 'usage.column.model', defaultWidth: 132, minWidth: 104, align: 'left' },
|
||||
@@ -90,11 +96,11 @@ const EVENT_COLUMNS: readonly EventColumnDef[] = [
|
||||
{ key: 'speed', labelKey: 'usage.column.speed', defaultWidth: 88, minWidth: 72, align: 'left' },
|
||||
{ key: 'total', labelKey: 'usage.column.tokens', defaultWidth: 120, minWidth: 112, align: 'left' },
|
||||
{ key: 'cache', labelKey: 'usage.column.cache', defaultWidth: 104, minWidth: 88, align: 'left' },
|
||||
{ key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 108, minWidth: 88, align: 'left' },
|
||||
{ key: 'cost', labelKey: 'usage.column.cost', defaultWidth: 112, minWidth: 96, align: 'left' },
|
||||
] as const;
|
||||
|
||||
const DEFAULT_EVENT_VISIBLE_COLUMNS: readonly EventColumnKey[] = [
|
||||
'time', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider',
|
||||
'time', 'provider', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'cost',
|
||||
];
|
||||
const LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly string[] = [
|
||||
'time', 'model', 'input', 'output', 'cache', 'cacheRate', 'total', 'speed', 'ttft', 'latency', 'result', 'provider', 'source',
|
||||
@@ -105,16 +111,18 @@ const LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v1'
|
||||
const PREVIOUS_EVENT_COLUMN_WIDTHS: Record<EventColumnKey, number> = {
|
||||
time: 105, key: 150, source: 205, model: 150, effort: 100, result: 95,
|
||||
request: 145, latency: 125, speed: 110, total: 145, cache: 135, provider: 135,
|
||||
cost: 112,
|
||||
};
|
||||
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v3';
|
||||
const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v2';
|
||||
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v4';
|
||||
const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v3';
|
||||
|
||||
const getAllEventColumnKeys = () => EVENT_COLUMNS.map((column) => column.key);
|
||||
|
||||
const getInitialVisibleColumns = (): EventColumnKey[] => {
|
||||
try {
|
||||
const currentRaw = localStorage.getItem(EVENT_VISIBLE_COLS_STORAGE_KEY);
|
||||
const raw = currentRaw ?? localStorage.getItem(LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY);
|
||||
const raw = currentRaw ?? localStorage.getItem(LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY)
|
||||
?? localStorage.getItem('cpa-gui.usage-events-visible-cols.v2');
|
||||
if (raw) {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -131,7 +139,8 @@ const getInitialVisibleColumns = (): EventColumnKey[] => {
|
||||
const wasLegacyDefault = currentRaw === null
|
||||
&& parsed.length === LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.length
|
||||
&& LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key));
|
||||
return wasLegacyDefault ? [...DEFAULT_EVENT_VISIBLE_COLUMNS] : savedKeys;
|
||||
if (wasLegacyDefault) return [...DEFAULT_EVENT_VISIBLE_COLUMNS];
|
||||
return currentRaw === null && !savedKeys.includes('cost') ? [...savedKeys, 'cost'] : savedKeys;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,34 +297,50 @@ function UsageEventCell({
|
||||
</td>
|
||||
);
|
||||
case 'model': {
|
||||
const responseModel = (record.response_model ?? '').trim();
|
||||
const modelTitle = responseModel ? [
|
||||
`${t('usage.model.request')}: ${record.alias || record.model}`,
|
||||
`${t('usage.model.upstream')}: ${record.model}`,
|
||||
`${t('usage.model.response')}: ${responseModel}`,
|
||||
].join('\n') : undefined;
|
||||
const model = usageModelDetails(record.model, record.alias, record.response_model);
|
||||
const modelTitle = [
|
||||
`${t('usage.model.request')}: ${model.requested}`,
|
||||
model.showResolved ? `${t('usage.model.upstream')}: ${model.resolved}` : '',
|
||||
model.showResponseInTooltip ? `${t('usage.model.response')}: ${model.response}` : '',
|
||||
model.mismatch ? t('usage.model.mismatch') : '',
|
||||
model.showResponseInTooltip ? t('usage.model.responseHint') : '',
|
||||
].filter(Boolean).join('\n');
|
||||
return (
|
||||
<td className="usage-stacked-cell usage-td-model align-left" title={modelTitle}>
|
||||
<strong title={modelTitle || record.alias || record.model}>{record.alias || record.model}</strong>
|
||||
{record.alias && record.alias !== record.model ? <small title={modelTitle || record.model}>{record.model}</small> : null}
|
||||
{responseModel ? (
|
||||
<strong title={modelTitle}>{model.requested}</strong>
|
||||
{model.showResolved ? <small title={modelTitle}>{model.resolved}</small> : null}
|
||||
{model.mismatch ? (
|
||||
<small className="usage-response-model" title={modelTitle}>
|
||||
<span aria-hidden="true">↳ </span>{t('usage.model.response')}: {responseModel}
|
||||
{t('usage.model.response')}: {model.response}
|
||||
</small>
|
||||
) : null}
|
||||
{model.mismatch ? <span className="usage-model-mismatch">{t('usage.model.mismatch')}</span> : null}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'cost': {
|
||||
const cost = record.cost;
|
||||
const amount = cost ? new Intl.NumberFormat(getCurrentLocale(), {
|
||||
style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol', minimumFractionDigits: 2, maximumFractionDigits: 6,
|
||||
}).format(cost.total) : '—';
|
||||
return <td className="usage-td-cost usage-stacked-cell align-left" title={cost ? t('usage.cost.hint', { model: cost.pricing_model }) : t('usage.cost.unpriced')}>
|
||||
<strong>{amount}</strong>
|
||||
{!cost ? <small>{t('usage.cost.unpriced')}</small> : null}
|
||||
</td>;
|
||||
}
|
||||
case 'effort':
|
||||
return <td className="usage-stacked-cell align-left" title={record.reasoning_effort || 'auto'}><strong>{record.reasoning_effort || 'auto'}</strong></td>;
|
||||
case 'request':
|
||||
return <td className="usage-stacked-cell align-left" title={record.endpoint || undefined}><strong>{record.endpoint || '—'}</strong></td>;
|
||||
case 'provider':
|
||||
case 'provider': {
|
||||
const provider = usageProviderDetails(record.provider, record.auth_type);
|
||||
return (
|
||||
<td className="usage-td-provider align-left" title={record.provider || undefined}>
|
||||
<span className="usage-tag-pill">{record.provider || '—'}</span>
|
||||
<td className="usage-td-provider usage-stacked-cell align-left" title={`${t('usage.provider.hint')}\nprovider: ${provider.rawProvider || '—'}\nauth_type: ${provider.rawAuthType || '—'}`}>
|
||||
<strong>{provider.name}</strong>
|
||||
<small className="usage-access-type">{provider.access || t('usage.provider.unknownAccess')}</small>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'source':
|
||||
return (
|
||||
<td className="usage-td-source align-left" title={record.source_display || record.source || undefined}>
|
||||
@@ -548,13 +573,13 @@ export function EventsView({
|
||||
const endRecordNum = Math.min(events.page * pageSize, events.total);
|
||||
|
||||
const exportCurrentPage = () => {
|
||||
const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'response_model', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens'];
|
||||
const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'response_model', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens', 'estimated_cost_usd', 'pricing_model'];
|
||||
const csvCell = (value: string | number | boolean | null) => {
|
||||
const text = value == null ? '' : String(value);
|
||||
const safe = typeof value === 'string' && /^[\s\u0000-\u001f]*[=+@-]/.test(text) ? `'${text}` : text;
|
||||
return `"${safe.replace(/"/g, '""')}"`;
|
||||
};
|
||||
const rows = events.items.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.response_model ?? '', record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens]);
|
||||
const rows = events.items.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.response_model ?? '', record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens, record.cost?.total ?? null, record.cost?.pricing_model ?? '']);
|
||||
const csv = '\uFEFF' + [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
|
||||
const link = document.createElement('a');
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Match CPA-Manager-Plus request monitoring: compare the response with the routed model. */
|
||||
export function usageModelDetails(model: string, alias = '', response = '') {
|
||||
const resolved = model.trim();
|
||||
const requested = alias.trim() || resolved || '—';
|
||||
const responseModel = response.trim();
|
||||
return {
|
||||
requested, resolved, response: responseModel,
|
||||
showResolved: Boolean(resolved && resolved !== requested),
|
||||
mismatch: Boolean(responseModel && resolved && responseModel !== resolved),
|
||||
showResponseInTooltip: Boolean(responseModel && (!resolved || responseModel !== resolved)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Usage API values describe the route, not the brand implied by a model name. */
|
||||
export function usageProviderDetails(provider: string, authType = '') {
|
||||
const rawProvider = provider.trim();
|
||||
const rawAuthType = authType.trim();
|
||||
const labels: Record<string, string> = {
|
||||
codex: 'Codex', claude: 'Claude', gemini: 'Gemini', aistudio: 'AI Studio',
|
||||
antigravity: 'Antigravity', openai: 'OpenAI', 'openai-compatibility': 'OpenAI Compatible',
|
||||
vertex: 'Vertex AI', 'vertex-ai': 'Vertex AI', xai: 'xAI', grok: 'Grok',
|
||||
};
|
||||
return {
|
||||
name: labels[rawProvider.toLowerCase()] ?? (rawProvider || '—'),
|
||||
access: rawAuthType.toLowerCase() === 'apikey' ? 'API'
|
||||
: rawAuthType.toLowerCase() === 'oauth' ? 'OAuth'
|
||||
: rawAuthType && rawAuthType.toLowerCase() !== 'unknown' ? rawAuthType : '',
|
||||
rawProvider,
|
||||
rawAuthType,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user