From e778687774e0286e1dd1a4b173ac75d57f398501 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 30 Jul 2026 15:25:50 +0800 Subject: [PATCH 1/4] Show partial agent session failures in observability UI --- packages/core/src/contracts/app.ts | 2 +- .../src/observability/request-log-store.ts | 6 +- .../observability/request-log-store.test.mjs | 86 +++++++++++++ .../src/pages/home/components/dashboard.tsx | 36 +++++- packages/ui/src/pages/home/shared/i18n.tsx | 4 +- .../ui/test/component/observability.test.tsx | 116 +++++++++++++++++- 6 files changed, 240 insertions(+), 10 deletions(-) diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index e190f811..0fa8afa8 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -2280,7 +2280,7 @@ export type AgentAnalysisSubagentRow = { export type AgentAnalysisTraceRunKind = "agent" | "llm" | "route" | "subagent" | "tool"; -export type AgentAnalysisTraceRunStatus = "error" | "success"; +export type AgentAnalysisTraceRunStatus = "error" | "partial" | "success"; export type AgentAnalysisTracePayloadPreview = { kind: "empty" | "json" | "text"; diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index 02605f6d..f5313972 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -2579,7 +2579,11 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace { outputTokens: totals.outputTokens, sessionId, startedAt: isoFromMs(startMs), - status: totals.errorCount > 0 ? "error" : "success", + status: totals.errorCount === 0 + ? "success" + : totals.errorCount === totals.requestCount + ? "error" + : "partial", totalTokens: totals.totalTokens } ]; diff --git a/packages/core/test/integration/observability/request-log-store.test.mjs b/packages/core/test/integration/observability/request-log-store.test.mjs index 9850cb85..5c7d4d82 100644 --- a/packages/core/test/integration/observability/request-log-store.test.mjs +++ b/packages/core/test/integration/observability/request-log-store.test.mjs @@ -987,6 +987,10 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async assert.equal(selected.selectedSession?.trace.toolRunCount, 1); assert.equal(selected.selectedSession?.trace.llmRunCount, 1); assert.equal(selected.selectedSession?.trace.runs.some((run) => run.toolName === "read_file"), true); + assert.equal( + selected.selectedSession?.trace.runs.find((run) => run.id === selected.selectedSession?.trace.rootRunId)?.status, + "success" + ); const inputPayload = await store.getTracePayload({ callId: "call-read", @@ -1010,6 +1014,88 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async } }); +test("RequestLogStore distinguishes partial session failures from failed sessions", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-agent-status-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const baseTime = Date.now() - 5000; + + async function recordAgentRequest({ offsetMs, requestId, sessionId, statusCode }) { + const startedAtMs = baseTime + offsetMs; + await store.record({ + completedAt: new Date(startedAtMs + 100).toISOString(), + durationMs: 100, + ...(statusCode >= 400 ? { error: "upstream request failed" } : {}), + method: "POST", + path: "/v1/chat/completions", + providerName: "test-provider", + providerProtocol: "openai_chat_completions", + requestBody: Buffer.from(JSON.stringify({ + messages: [{ content: "continue task", role: "user" }], + model: "gpt-test", + session_id: sessionId + }), "utf8"), + requestHeaders: { + "content-type": "application/json", + "user-agent": "openai-codex test", + "x-codex-session-id": sessionId + }, + requestId, + responseBodyText: JSON.stringify({ model: "gpt-test" }), + responseHeaders: { "content-type": "application/json" }, + startedAt: new Date(startedAtMs).toISOString(), + statusCode, + url: "http://127.0.0.1:3456/v1/chat/completions" + }); + } + + await recordAgentRequest({ + offsetMs: 0, + requestId: "mixed-failed", + sessionId: "session-mixed", + statusCode: 502 + }); + await recordAgentRequest({ + offsetMs: 1000, + requestId: "mixed-recovered", + sessionId: "session-mixed", + statusCode: 200 + }); + await recordAgentRequest({ + offsetMs: 2000, + requestId: "failed-only", + sessionId: "session-failed", + statusCode: 502 + }); + + const mixed = await store.analyze({ + range: "30d", + sessionAgent: "codex", + sessionId: "session-mixed" + }); + const mixedTrace = mixed.selectedSession?.trace; + assert.equal( + mixedTrace?.runs.find((run) => run.id === mixedTrace.rootRunId)?.status, + "partial" + ); + assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "error"), true); + assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "success"), true); + + const failed = await store.analyze({ + range: "30d", + sessionAgent: "codex", + sessionId: "session-failed" + }); + const failedTrace = failed.selectedSession?.trace; + assert.equal( + failedTrace?.runs.find((run) => run.id === failedTrace.rootRunId)?.status, + "error" + ); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + test("RequestLogStore agent analysis cache ratio denominator includes cache tokens when total tokens omit cache", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-cache-ratio-test-")); try { diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 39ed82a5..4d293566 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -3557,19 +3557,22 @@ function AgentSessionDetailCard({ const agentListSurfaceClassName = "rounded-md border border-border/70 bg-card/70 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"; const agentListFrameClassName = cn("overflow-auto", agentListSurfaceClassName); const agentListTableClassName = "w-full border-collapse text-left text-[11px]"; -const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur"; +const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur [&_th]:min-w-[64px] [&_th]:whitespace-nowrap"; const agentListBodyClassName = "divide-y divide-border/50"; function agentListRowClassName({ danger, - selected + selected, + warning }: { danger?: boolean; selected?: boolean; + warning?: boolean; } = {}) { return cn( "bg-card/40 transition-colors hover:bg-muted/30", danger && "bg-rose-500/5 hover:bg-rose-500/10", + warning && "bg-amber-500/5 hover:bg-amber-500/10", selected && "bg-teal-500/10 shadow-[inset_2px_0_0_rgba(20,184,166,0.7)] hover:bg-teal-500/15" ); } @@ -3613,7 +3616,10 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) { {trace.runs.map((run) => ( - +
@@ -3636,8 +3642,8 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
- - {t(run.status === "error" ? "Error" : "Success")} + + {t(traceRunStatusLabel(run.status))} @@ -4000,6 +4006,7 @@ function traceRunBarStyle(run: AgentAnalysisTraceRun, traceDurationMs: number): function traceRunDotClass(run: AgentAnalysisTraceRun): string { if (run.status === "error") return "bg-rose-500"; + if (run.status === "partial") return "bg-amber-500"; if (run.kind === "agent") return "bg-teal-500"; if (run.kind === "route") return "bg-cyan-500"; if (run.kind === "subagent") return "bg-amber-500"; @@ -4009,6 +4016,7 @@ function traceRunDotClass(run: AgentAnalysisTraceRun): string { function traceRunBarClass(run: AgentAnalysisTraceRun): string { if (run.status === "error") return "bg-rose-500"; + if (run.status === "partial") return "bg-amber-500"; if (run.kind === "agent") return "bg-teal-500"; if (run.kind === "route") return "bg-cyan-500"; if (run.kind === "subagent") return "bg-amber-500"; @@ -4016,6 +4024,18 @@ function traceRunBarClass(run: AgentAnalysisTraceRun): string { return "bg-blue-500"; } +function traceRunStatusBadgeClass(status: AgentAnalysisTraceRun["status"]): string { + if (status === "error") return "border-rose-200 bg-rose-50 text-rose-700"; + if (status === "partial") return "border-amber-200 bg-amber-50 text-amber-700"; + return "border-emerald-200 bg-emerald-50 text-emerald-700"; +} + +function traceRunStatusLabel(status: AgentAnalysisTraceRun["status"]): string { + if (status === "error") return "Error"; + if (status === "partial") return "Partial failure"; + return "Success"; +} + function formatRouteReason(value: string | undefined): string { const trimmed = value?.trim(); if (!trimmed) { @@ -4041,7 +4061,7 @@ function AgentSessionsCard({ ) : (
- +
@@ -4054,6 +4074,8 @@ function AgentSessionsCard({ + + @@ -4077,6 +4099,8 @@ function AgentSessionsCard({ + + diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 9ccb8e25..ab08f431 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -1045,6 +1045,7 @@ export const appCopy: Record = { "Model overrides are optional; empty fields keep Claude Code defaults.": "模型设置是可选项;留空会保留 Claude Code 默认设置。", "Display name": "显示名称", "Double click to copy": "双击复制", + "Duration": "持续时间", "Edit": "编辑", "Edit bot": "编辑 Bot", "Edit API Key": "编辑 API 密钥", @@ -1300,6 +1301,7 @@ export const appCopy: Record = { "Profile stopping is only available in the Electron app.": "配置档案停止功能仅在 Electron App 中可用。", "Profile ready": "配置档案已就绪", "Password": "密码", + "Partial failure": "部分失败", "Recent Errors": "最近错误", "Recent Requests": "最近请求", "Refresh": "刷新", @@ -1569,7 +1571,7 @@ export const appCopy: Record = { "Subagent": "子代理", "Subagent Routing": "Subagent 路由", "Subagent calls": "Subagent 调用", - "Subagents": "Subagent", + "Subagents": "子代理", "Success": "成功", "Success rate": "成功率", "System proxy": "系统代理", diff --git a/packages/ui/test/component/observability.test.tsx b/packages/ui/test/component/observability.test.tsx index 96d9adde..cb970919 100644 --- a/packages/ui/test/component/observability.test.tsx +++ b/packages/ui/test/component/observability.test.tsx @@ -2,9 +2,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import type { RequestLogPage } from "@ccr/core/contracts/app.ts"; +import type { AgentAnalysisSessionRow, AgentAnalysisTraceRun, RequestLogPage } from "@ccr/core/contracts/app.ts"; +import { AgentAnalysisView } from "@ccr/ui/pages/home/components/dashboard.tsx"; import { LogsView } from "@ccr/ui/pages/home/components/network-logs.tsx"; import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx"; +import { createEmptyAgentAnalysis } from "@ccr/ui/pages/home/shared/usage.ts"; const emptyLogPage: RequestLogPage = { generatedAt: "2026-07-23T00:00:00.000Z", @@ -59,3 +61,115 @@ test("LogsView explains filtered empty results and translates page sizes", () => assert.match(html, /25 \/ page/); assert.doesNotMatch(html, /\/ 页/); }); + +test("AgentAnalysisView keeps session headings horizontal and shows cache rate and cost", () => { + const session: AgentAnalysisSessionRow = { + agent: "claude-code", + avgDurationMs: 420, + cacheRatio: 0.375, + cacheReadTokens: 300, + cacheTokens: 300, + cacheWriteTokens: 100, + client: "claude-code", + costUsd: 1.25, + durationMs: 900, + errorCount: 0, + id: "session-cache-cost", + inputTokens: 500, + lastSeenAt: "2026-07-23T00:01:00.000Z", + maxConcurrentRequests: 1, + maxDurationMs: 500, + models: ["claude-sonnet-4"], + outputTokens: 100, + p50DurationMs: 420, + p95DurationMs: 500, + p99DurationMs: 500, + providers: ["anthropic"], + requestCount: 2, + sessionCount: 1, + startedAt: "2026-07-23T00:00:00.000Z", + subagentCallCount: 0, + successRate: 1, + toolCallCount: 1, + topTools: [{ count: 1, name: "Read" }], + totalTokens: 1000 + }; + const snapshot = { + ...createEmptyAgentAnalysis("24h"), + scannedRequestCount: 2, + selectedSession: { + endpoints: [], + errors: [], + models: [], + requests: [], + routes: [], + session, + statusCodes: [], + subagents: [], + tools: [], + totals: session, + trace: { + agent: session.agent, + durationMs: session.durationMs, + endedAt: session.lastSeenAt, + errorCount: 1, + id: `${session.agent}:${session.id}`, + llmRunCount: 0, + maxDepth: 0, + rootRunId: `agent:${session.agent}:${session.id}`, + runCount: 1, + runs: [{ + agent: session.agent, + cacheReadTokens: session.cacheReadTokens, + cacheWriteTokens: session.cacheWriteTokens, + concurrentRequests: session.maxConcurrentRequests, + depth: 0, + durationMs: session.durationMs, + endedAt: session.lastSeenAt, + id: `agent:${session.agent}:${session.id}`, + inputTokens: session.inputTokens, + kind: "agent", + name: "Claude Code session", + offsetMs: 0, + outputTokens: session.outputTokens, + sessionId: session.id, + startedAt: session.startedAt, + status: "partial", + totalTokens: session.totalTokens + } satisfies AgentAnalysisTraceRun], + sessionId: session.id, + startedAt: session.startedAt, + subagentRunCount: 0, + toolRunCount: 0 + } + }, + sessions: [session] + }; + + const html = renderToStaticMarkup( + + undefined} + setAgentFilter={() => undefined} + setRange={() => undefined} + setSelectedSession={() => undefined} + snapshot={snapshot} + /> + + ); + + assert.match(html, /持续时间/); + assert.match(html, /子代理/); + assert.match(html, /缓存率/); + assert.match(html, /38%/); + assert.match(html, /成本/); + assert.match(html, /\$1\.25/); + assert.match(html, /部分失败/); + assert.match(html, /border-amber-200/); + assert.match(html, /min-w-\[64px\]/); + assert.match(html, /whitespace-nowrap/); +}); From 7b6a14301cf2e0ef0cb92f409a48f42e648fd7c0 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 30 Jul 2026 15:29:56 +0800 Subject: [PATCH 2/4] Refine subagent model extraction and add detection tests --- .../src/observability/request-log-store.ts | 58 +++++++++- .../observability/request-log-store.test.mjs | 101 ++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index f5313972..2964a672 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -1882,15 +1882,67 @@ function extractSubagentModel( } for (const payload of requestPayloads) { - const match = stringifyForSearch(payload).match(/(.*?)<\/CCR-SUBAGENT-MODEL>/s); - if (match?.[1]?.trim()) { - return match[1].trim(); + const model = extractPayloadSubagentModel(payload); + if (model) { + return model; } } return undefined; } +function extractPayloadSubagentModel(payload: unknown): string | undefined { + if (!isRecord(payload)) { + return undefined; + } + + const systemModel = extractSubagentModelFromContent(payload.system); + if (systemModel) { + return systemModel; + } + + if (!Array.isArray(payload.messages)) { + return undefined; + } + for (const message of payload.messages.slice(0, 2)) { + if (!isRecord(message) || message.role !== "user") { + continue; + } + const model = extractSubagentModelFromContent(message.content); + if (model) { + return model; + } + } + return undefined; +} + +function extractSubagentModelFromContent(content: unknown): string | undefined { + if (typeof content === "string") { + return extractSubagentModelFromText(content); + } + if (!Array.isArray(content)) { + return undefined; + } + for (const block of content) { + const text = typeof block === "string" + ? block + : isRecord(block) && typeof block.text === "string" + ? block.text + : undefined; + const model = text ? extractSubagentModelFromText(text) : undefined; + if (model) { + return model; + } + } + return undefined; +} + +function extractSubagentModelFromText(text: string): string | undefined { + const match = text.match(/(.*?)<\/CCR-SUBAGENT-MODEL>/s); + const model = match?.[1]?.trim(); + return model && model.toLowerCase() !== "provider/model" ? model : undefined; +} + function parseLogBodyPayloads(body: RequestLogBody | undefined): unknown[] { if (!body || body.encoding !== "utf8" || !body.text.trim()) { return []; diff --git a/packages/core/test/integration/observability/request-log-store.test.mjs b/packages/core/test/integration/observability/request-log-store.test.mjs index 5c7d4d82..d78f1a44 100644 --- a/packages/core/test/integration/observability/request-log-store.test.mjs +++ b/packages/core/test/integration/observability/request-log-store.test.mjs @@ -1014,6 +1014,107 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async } }); +test("RequestLogStore ignores subagent markers in tool definitions and placeholder text", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-subagent-detection-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const baseTime = Date.now() - 5000; + + async function recordClaudeRequest({ body, offsetMs, sessionId }) { + const startedAtMs = baseTime + offsetMs; + await store.record({ + completedAt: new Date(startedAtMs + 100).toISOString(), + durationMs: 100, + method: "POST", + path: "/v1/messages", + providerName: "test-provider", + requestBody: Buffer.from(JSON.stringify({ + ...body, + model: "claude-test" + }), "utf8"), + requestHeaders: { + "content-type": "application/json", + "user-agent": "claude-cli test", + "x-ccr-route-reason": "default", + "x-ccr-routed-model": "test-provider/claude-test", + "x-claude-code-session-id": sessionId + }, + requestId: `${sessionId}-request`, + responseBodyText: JSON.stringify({ model: "claude-test" }), + responseHeaders: { "content-type": "application/json" }, + startedAt: new Date(startedAtMs).toISOString(), + statusCode: 200, + url: "http://127.0.0.1:3456/v1/messages" + }); + } + + await recordClaudeRequest({ + body: { + messages: [{ content: "normal main-agent request", role: "user" }], + tools: [{ + description: "Use Provider/claude-opus when spawning an agent.", + input_schema: { + properties: { + prompt: { + description: "Start with Provider/model.", + type: "string" + } + }, + type: "object" + }, + name: "Agent" + }] + }, + offsetMs: 0, + sessionId: "tool-description-session" + }); + await recordClaudeRequest({ + body: { + messages: [{ content: "normal request", role: "user" }], + system: "Example: Provider/model" + }, + offsetMs: 1000, + sessionId: "placeholder-session" + }); + await recordClaudeRequest({ + body: { + messages: [{ + content: "Provider/claude-opus\nInspect the repository.", + role: "user" + }] + }, + offsetMs: 2000, + sessionId: "real-subagent-session" + }); + + const toolDescription = await store.analyze({ + range: "30d", + sessionAgent: "claude-code", + sessionId: "tool-description-session" + }); + assert.equal(toolDescription.selectedSession?.trace.subagentRunCount, 0); + assert.equal(toolDescription.selectedSession?.subagents.length, 0); + + const placeholder = await store.analyze({ + range: "30d", + sessionAgent: "claude-code", + sessionId: "placeholder-session" + }); + assert.equal(placeholder.selectedSession?.trace.subagentRunCount, 0); + assert.equal(placeholder.selectedSession?.subagents.length, 0); + + const realSubagent = await store.analyze({ + range: "30d", + sessionAgent: "claude-code", + sessionId: "real-subagent-session" + }); + assert.equal(realSubagent.selectedSession?.trace.subagentRunCount, 1); + assert.equal(realSubagent.selectedSession?.subagents[0]?.model, "Provider/claude-opus"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + test("RequestLogStore distinguishes partial session failures from failed sessions", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-agent-status-test-")); try { From 571aa6360236fea485f09022722492c5a5041b4d Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 30 Jul 2026 17:32:34 +0800 Subject: [PATCH 3/4] Show request and trace costs in agent observability --- packages/core/src/contracts/app.ts | 1 + .../src/observability/request-log-store.ts | 2 ++ .../request-log-pricing-transaction.test.mjs | 27 +++++++++++++++++ .../src/pages/home/components/dashboard.tsx | 8 +++-- .../ui/test/component/observability.test.tsx | 29 ++++++++++++++++++- 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 0fa8afa8..15a00fbb 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -2318,6 +2318,7 @@ export type AgentAnalysisTraceRun = { cacheReadTokens: number; cacheWriteTokens: number; concurrentRequests: number; + costUsd?: number; depth: number; durationMs: number; endedAt: string; diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index 2964a672..052c21d9 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -2620,6 +2620,7 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace { cacheReadTokens: totals.cacheReadTokens, cacheWriteTokens: totals.cacheWriteTokens, concurrentRequests: totals.maxConcurrentRequests, + costUsd: totals.costUsd, depth: 0, durationMs, endedAt: isoFromMs(endMs), @@ -2760,6 +2761,7 @@ function requestTraceRun({ cacheReadTokens: request.cacheReadTokens, cacheWriteTokens: request.cacheWriteTokens, concurrentRequests: request.concurrentRequests, + costUsd: request.costUsd, depth, durationMs: request.durationMs, endedAt: isoFromMs(request.endedAtMs), diff --git a/packages/core/test/integration/observability/request-log-pricing-transaction.test.mjs b/packages/core/test/integration/observability/request-log-pricing-transaction.test.mjs index adb8551f..4ab7cb11 100644 --- a/packages/core/test/integration/observability/request-log-pricing-transaction.test.mjs +++ b/packages/core/test/integration/observability/request-log-pricing-transaction.test.mjs @@ -126,6 +126,11 @@ test("RequestLogStore applies persisted custom model pricing to raw trace usage ); record.model = "custom-model"; record.providerName = "custom-provider"; + record.requestHeaders = { + "content-type": "application/json", + "user-agent": "openai-codex test", + "x-codex-session-id": "custom-pricing-session" + }; record.pricing = { inputUsdPerMillionTokens: 2, outputUsdPerMillionTokens: 8 @@ -141,6 +146,19 @@ test("RequestLogStore applies persisted custom model pricing to raw trace usage let page = await store.list({ pageSize: 10 }); let detail = await store.getDetail({ id: page.items[0].id }); assert.equal(detail.costUsd, 6); + let analysis = await store.analyze({ + range: "30d", + sessionAgent: "codex", + sessionId: "custom-pricing-session" + }); + assert.equal( + analysis.selectedSession?.trace.runs.find((run) => run.id === analysis.selectedSession?.trace.rootRunId)?.costUsd, + 6 + ); + assert.equal( + analysis.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.costUsd, + 6 + ); assert.equal(await store.updateFromRawTrace({ model: "custom-model", @@ -151,6 +169,15 @@ test("RequestLogStore applies persisted custom model pricing to raw trace usage page = await store.list({ pageSize: 10 }); detail = await store.getDetail({ id: page.items[0].id }); assert.equal(detail.costUsd, 12); + analysis = await store.analyze({ + range: "30d", + sessionAgent: "codex", + sessionId: "custom-pricing-session" + }); + assert.equal( + analysis.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.costUsd, + 12 + ); } finally { await store.close(); rmSync(dir, { force: true, recursive: true }); diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 4d293566..3dd119e8 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -3517,7 +3517,7 @@ function AgentSessionDetailCard({ ) : (
-
{t("Session")}{t("Tools")} {t("Subagents")} {t("Errors")}{t("Cache rate")}{t("Cost")} {t("Models")} {t("Providers")} {t("UA")}{formatCompactNumber(session.toolCallCount)} {formatCompactNumber(session.subagentCallCount)} {formatCompactNumber(session.errorCount)}{formatPercent(session.cacheRatio)}{formatUsdCost(session.costUsd)} {session.models.join(", ") || "-"} {session.providers.join(", ") || "-"} {compactUserAgent(session.userAgent)}
+
@@ -3526,6 +3526,7 @@ function AgentSessionDetailCard({ + @@ -3538,6 +3539,7 @@ function AgentSessionDetailCard({ + ))} @@ -3601,7 +3603,7 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) { ) : (
-
{t("Time")}{t("Model")} {t("Tools")} {t("Tokens")}{t("Cost")} {t("Duration")}
{request.provider}/{request.model} {formatCompactNumber(request.toolCallCount)} {formatCompactNumber(request.totalTokens)}{formatUsdCost(request.costUsd ?? 0)} {formatDuration(request.durationMs)}
+
@@ -3610,6 +3612,7 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) { + @@ -3651,6 +3654,7 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) { + diff --git a/packages/ui/test/component/observability.test.tsx b/packages/ui/test/component/observability.test.tsx index cb970919..4f0d2dd4 100644 --- a/packages/ui/test/component/observability.test.tsx +++ b/packages/ui/test/component/observability.test.tsx @@ -101,7 +101,31 @@ test("AgentAnalysisView keeps session headings horizontal and shows cache rate a endpoints: [], errors: [], models: [], - requests: [], + requests: [{ + agent: session.agent, + cacheReadTokens: 300, + cacheWriteTokens: 100, + client: session.client, + concurrentRequests: 1, + costUsd: 0.25, + createdAt: session.startedAt, + durationMs: 420, + id: 42, + inputTokens: 500, + method: "POST", + model: "claude-sonnet-4", + ok: true, + outputTokens: 100, + path: "/v1/messages", + provider: "anthropic", + requestId: "request-with-cost", + routeReason: "default", + sessionId: session.id, + statusCode: 200, + toolCallCount: 1, + tools: ["Read"], + totalTokens: 1000 + }], routes: [], session, statusCodes: [], @@ -123,6 +147,7 @@ test("AgentAnalysisView keeps session headings horizontal and shows cache rate a cacheReadTokens: session.cacheReadTokens, cacheWriteTokens: session.cacheWriteTokens, concurrentRequests: session.maxConcurrentRequests, + costUsd: 0.75, depth: 0, durationMs: session.durationMs, endedAt: session.lastSeenAt, @@ -168,6 +193,8 @@ test("AgentAnalysisView keeps session headings horizontal and shows cache rate a assert.match(html, /38%/); assert.match(html, /成本/); assert.match(html, /\$1\.25/); + assert.match(html, /\$0\.25/); + assert.match(html, /\$0\.75/); assert.match(html, /部分失败/); assert.match(html, /border-amber-200/); assert.match(html, /min-w-\[64px\]/); From 43f99b2de8607bdfe05d5f8cc2df2b1c01f5f5f4 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 30 Jul 2026 18:44:03 +0800 Subject: [PATCH 4/4] Use Token consistently in Chinese UI labels --- .../src/pages/home/components/dashboard.tsx | 6 +- .../pages/home/components/network-logs.tsx | 4 +- packages/ui/src/pages/home/shared/i18n.tsx | 26 +++++---- .../ui/test/component/observability.test.tsx | 55 ++++++++++++++++++- .../component/overview-components.test.tsx | 29 ++++++++++ 5 files changed, 102 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 3dd119e8..c6852087 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -3525,7 +3525,7 @@ function AgentSessionDetailCard({ - + @@ -3610,7 +3610,7 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) { - + @@ -4171,7 +4171,7 @@ function UsageAnalysisCard({ {visibleColumns.map((column) => ( ))} - + {showCost ? : null} {showTokenBreakdown ? : null} diff --git a/packages/ui/src/pages/home/components/network-logs.tsx b/packages/ui/src/pages/home/components/network-logs.tsx index e7931545..bf5a2aa4 100644 --- a/packages/ui/src/pages/home/components/network-logs.tsx +++ b/packages/ui/src/pages/home/components/network-logs.tsx @@ -782,7 +782,7 @@ function logTableColumnLabel(columnId: LogTableColumnId, t: (value: string) => s case "credential": return t("Credential"); case "tokens": - return t("令牌"); + return t("Token"); case "duration": return t("持续时间"); } @@ -849,7 +849,7 @@ function LogMobileCard({
- + {hasCredentialInfo ? : null} diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index ab08f431..fd1f0e09 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -227,6 +227,7 @@ export const appCopy: Record = { "Stream": "Stream", "Streaming": "Streaming", "Non-streaming": "Non-streaming", + "Token": "Token", "令牌": "Tokens", "成本": "Cost", "持续时间": "Duration", @@ -880,7 +881,7 @@ export const appCopy: Record = { "Cache": "缓存", "Cache rate": "缓存率", "Cache ratio": "缓存率", - "Cache tokens": "缓存令牌", + "Cache tokens": "缓存 Token", "Cache write": "缓存写入", "Cancel": "取消", "Channel": "频道", @@ -1121,7 +1122,7 @@ export const appCopy: Record = { "Idle seconds": "空闲秒数", "Idle seconds must be between 30 and 86400.": "空闲秒数必须在 30 到 86400 之间。", "Input": "输入", - "Input tokens": "输入令牌", + "Input tokens": "输入 Token", "Integration ID": "集成 ID", "Install": "安装", "Install and restart": "安装并重启", @@ -1209,7 +1210,7 @@ export const appCopy: Record = { "Open profiles": "查看配置档案", "Open providers": "查看供应商", "Output": "输出", - "Output tokens": "输出令牌", + "Output tokens": "输出 Token", "Observability": "可观测", "Off": "关闭", "On failure": "失败时", @@ -1530,7 +1531,7 @@ export const appCopy: Record = { "AI Fuel Cockpit": "AI 燃料仪表", "Token Calendar Poster": "Token 日历海报", "Spend Receipt": "消费小票", - "tokens routed through CCR": "通过 CCR 路由的令牌", + "tokens routed through CCR": "通过 CCR 路由的 Token", "Top model": "最高频模型", "Top provider": "最高频供应商", "No provider activity": "暂无供应商活动", @@ -1583,15 +1584,16 @@ export const appCopy: Record = { "Thread": "线程", "Thread ID": "线程 ID", "Thinking": "思考", - "Token Mix": "令牌构成", + "Token": "Token", + "Token Mix": "Token 构成", "Total": "总计", - "Total tokens": "总令牌", + "Total tokens": "总 Token", "Username": "用户名", "Today": "今天", - "Token threshold": "令牌阈值", + "Token threshold": "Token 阈值", "Truncated": "已截断", - "Tokens": "令牌", - "tokens": "令牌", + "Tokens": "Token", + "tokens": "Token", "day": "天", "days": "天", "Tool": "工具", @@ -1814,10 +1816,10 @@ export const appCopy: Record = { "5h quota": "5 小时额度", "Granted balance": "赠送余额", "Individual limit": "个人额度", - "Lifetime tokens": "累计令牌", + "Lifetime tokens": "累计 Token", "Manual resets": "主动重置次数", "Monthly budget": "月度预算", - "Peak daily tokens": "日峰值令牌", + "Peak daily tokens": "日峰值 Token", "Primary quota": "主额度", "Secondary quota": "副额度", "Topped-up balance": "充值余额", @@ -1846,7 +1848,7 @@ export const appCopy: Record = { "Copy API key": "复制 API 密钥", "Model settings": "模型设置", "Context, pricing, reasoning, web search, and image": "上下文、价格、推理、网页搜索与图片", - "Context window (tokens)": "上下文窗口(令牌)", + "Context window (tokens)": "上下文窗口(Token)", "Custom pricing": "自定义价格", "Pricing": "价格", "Preset": "预设", diff --git a/packages/ui/test/component/observability.test.tsx b/packages/ui/test/component/observability.test.tsx index 4f0d2dd4..69b33df8 100644 --- a/packages/ui/test/component/observability.test.tsx +++ b/packages/ui/test/component/observability.test.tsx @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import type { AgentAnalysisSessionRow, AgentAnalysisTraceRun, RequestLogPage } from "@ccr/core/contracts/app.ts"; +import type { AgentAnalysisSessionRow, AgentAnalysisTraceRun, RequestLogEntry, RequestLogPage } from "@ccr/core/contracts/app.ts"; import { AgentAnalysisView } from "@ccr/ui/pages/home/components/dashboard.tsx"; import { LogsView } from "@ccr/ui/pages/home/components/network-logs.tsx"; import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx"; @@ -22,6 +22,39 @@ const emptyLogPage: RequestLogPage = { totalPages: 1 }; +const sampleRequestLogEntry: RequestLogEntry = { + cacheReadTokens: 0, + cacheWriteTokens: 0, + client: "claude-code", + costUsd: 0.01, + createdAt: "2026-07-23T00:00:00.000Z", + credentialChain: [], + credentialSaturated: false, + durationMs: 120, + id: 1, + inputTokens: 100, + isStream: true, + method: "POST", + model: "claude-sonnet-4", + ok: true, + outputTokens: 50, + path: "/v1/messages", + provider: "anthropic", + reasoningTokens: 0, + requestBody: { encoding: "utf8", sizeBytes: 2, text: "{}", truncated: false }, + requestHeaders: {}, + requestId: "req-token-copy", + routeAttemptCount: 1, + routeHopCount: 1, + routeTraceTruncated: false, + retryAttempts: [], + responseBody: { encoding: "utf8", sizeBytes: 2, text: "{}", truncated: false }, + responseHeaders: {}, + statusCode: 200, + totalTokens: 150, + url: "/v1/messages" +}; + test("LogsView keeps disabled request logs discoverable with an enable action", () => { const html = renderToStaticMarkup( @@ -62,6 +95,24 @@ test("LogsView explains filtered empty results and translates page sizes", () => assert.doesNotMatch(html, /\/ 页/); }); +test("LogsView keeps Chinese token column copy as Token", () => { + const html = renderToStaticMarkup( + + undefined} + updateFilter={() => undefined} + /> + + ); + + assert.match(html, /Token/); + assert.doesNotMatch(html, /令牌/); +}); + test("AgentAnalysisView keeps session headings horizontal and shows cache rate and cost", () => { const session: AgentAnalysisSessionRow = { agent: "claude-code", @@ -192,6 +243,8 @@ test("AgentAnalysisView keeps session headings horizontal and shows cache rate a assert.match(html, /缓存率/); assert.match(html, /38%/); assert.match(html, /成本/); + assert.match(html, /Token/); + assert.doesNotMatch(html, /令牌/); assert.match(html, /\$1\.25/); assert.match(html, /\$0\.25/); assert.match(html, /\$0\.75/); diff --git a/packages/ui/test/component/overview-components.test.tsx b/packages/ui/test/component/overview-components.test.tsx index 5bb9c654..c649011f 100644 --- a/packages/ui/test/component/overview-components.test.tsx +++ b/packages/ui/test/component/overview-components.test.tsx @@ -3,6 +3,7 @@ import test from "node:test"; import * as React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { formatCodexResetCardExpiry, formatCodexResetCardNumber, OverviewView } from "@ccr/ui/pages/home/components/dashboard.tsx"; +import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx"; import { parseStatusBucketDate } from "@ccr/ui/pages/home/shared/controls.tsx"; import { providerAccountMeterDetailValidityProgress } from "@ccr/ui/pages/home/shared/provider-accounts.ts"; import type { OverviewWidgetConfig, ProviderAccountSnapshot } from "@ccr/core/contracts/app.ts"; @@ -74,6 +75,34 @@ test("OverviewView renders every overview widget type", () => { assert.match(html, /Spend Receipt/); }); +test("OverviewView keeps Chinese token copy as Token", () => { + const html = renderToStaticMarkup( + + undefined} + setUsageRange={() => undefined} + usageRange="30d" + usageStats={usageStats("30d")} + onWidgetsChange={() => undefined} + /> + + ); + + assert.match(html, /Token/); + assert.match(html, /Token 构成/); + assert.match(html, /总 Token/); + assert.doesNotMatch(html, /令牌/); +}); + test("overview status dates accept ISO usage buckets", () => { assert.equal(parseStatusBucketDate("2026-06-20T00:00:00.000Z")?.toISOString(), "2026-06-20T00:00:00.000Z"); });
{t("Run")}{t("Target")} {t("Tokens")} {t("Cache")}{t("Cost")} {t("Concurrency")} {t("Duration")}
{run.totalTokens > 0 ? formatCompactNumber(run.totalTokens) : "-"} {run.cacheReadTokens + run.cacheWriteTokens > 0 ? formatCompactNumber(run.cacheReadTokens + run.cacheWriteTokens) : "-"}{run.costUsd !== undefined ? formatUsdCost(run.costUsd) : "-"} {formatCompactNumber(run.concurrentRequests)} {formatDuration(run.durationMs)}
{t("Route")} {t("Model")} {t("Tools")}{t("Tokens")}{t("Token")} {t("Cost")} {t("Duration")}
{t("Timeline")} {t("Status")} {t("Target")}{t("Tokens")}{t("Token")} {t("Cache")} {t("Cost")} {t("Concurrency")}{column.label}{t("Tokens")}{t("Token")}{t("Cost")}{t("Requests")}{t("Input")}