diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md
index 17bddc9..eef62b4 100644
--- a/.helloagents/CHANGELOG.md
+++ b/.helloagents/CHANGELOG.md
@@ -26,9 +26,9 @@
- **[proxy]**: 失败判定简化为非 2xx/超时即失败
- 方案: [202603141749_usage-summary](archive/2026-03/202603141749_usage-summary/)
- 文件: apps/worker/src/routes/proxy.ts
-- **[admin-ui/usage]**: 使用日志新增概述卡片(总量/成功/失败/错误率/平均延迟)
- - 方案: [202603141749_usage-summary](archive/2026-03/202603141749_usage-summary/)
- - 文件: apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx, tests/ui/usage.test.ts, .helloagents/modules/usage.md, .helloagents/modules/admin-ui.md
+- **[usage/proxy]**: 使用日志记录上游失败详情并在日志状态列展示
+ - 方案: [202603141815_usage-log-status](archive/2026-03/202603141815_usage-log-status/)
+ - 文件: apps/worker/migrations/0005_add_usage_error_fields.sql, apps/worker/src/db/schema.sql, apps/worker/src/services/usage.ts, apps/worker/src/routes/proxy.ts, apps/ui/src/core/types.ts, apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx, tests/ui/usage-status.test.ts, .helloagents/modules/usage.md, .helloagents/modules/admin-ui.md
- **[worker/proxy]**: 移除临时调试日志(请求入口/出口与上游响应汇总)
- 类型: 微调(无方案包)
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/proxy.ts
diff --git a/.helloagents/modules/admin-ui.md b/.helloagents/modules/admin-ui.md
index acb7126..f7472ba 100644
--- a/.helloagents/modules/admin-ui.md
+++ b/.helloagents/modules/admin-ui.md
@@ -25,7 +25,7 @@
- done-hub 站点仅使用 base_url,不再维护多地址输入
- 站点连通测试提示令牌成功/失败统计
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
-- 使用日志顶部展示概述(总量/成功/失败/错误率/平均延迟)
+- 使用日志状态列展示详细失败原因(状态码/错误码/摘要)
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
- 系统设置增加定时签到开关与时间(中国时间)
diff --git a/.helloagents/modules/usage.md b/.helloagents/modules/usage.md
index afd03fa..d90d9b6 100644
--- a/.helloagents/modules/usage.md
+++ b/.helloagents/modules/usage.md
@@ -14,7 +14,7 @@
- 查询结果附带渠道与令牌名称
- usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度
- 推理强度来自请求体 `reasoning` / `reasoning_effort` 字段
-- 前端使用日志列表计算概述(总量/成功/失败/错误率/平均延迟)
+- usage_logs 记录上游失败详情(status/code/message),用于日志状态展示
## 依赖关系
- `usage_logs` 表
diff --git a/apps/ui/src/core/types.ts b/apps/ui/src/core/types.ts
index ace58bb..5c56672 100644
--- a/apps/ui/src/core/types.ts
+++ b/apps/ui/src/core/types.ts
@@ -60,6 +60,9 @@ export type UsageLog = {
stream?: boolean | number | null;
reasoning_effort?: string | number | null;
status: string;
+ upstream_status?: number | null;
+ error_code?: string | null;
+ error_message?: string | null;
created_at: string;
};
diff --git a/apps/ui/src/core/utils.ts b/apps/ui/src/core/utils.ts
index 1eb7861..eb32591 100644
--- a/apps/ui/src/core/utils.ts
+++ b/apps/ui/src/core/utils.ts
@@ -79,40 +79,63 @@ export const buildPageItems = (current: number, total: number): PageItem[] => {
});
};
-export type UsageSummary = {
- total: number;
- success: number;
- failed: number;
- errorRate: number;
- avgLatencyMs: number | null;
- totalTokens: number;
+const truncateText = (value: string | null | undefined, max = 80) => {
+ if (!value) {
+ return null;
+ }
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return null;
+ }
+ return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
};
-export const summarizeUsageLogs = (logs: UsageLog[]): UsageSummary => {
- const total = logs.length;
- const success = logs.filter((log) => log.status === "ok").length;
- const failed = total - success;
- let latencySum = 0;
- let latencyCount = 0;
- let totalTokens = 0;
- for (const log of logs) {
- const latency = log.latency_ms;
- if (latency !== null && latency !== undefined && !Number.isNaN(latency)) {
- latencySum += latency;
- latencyCount += 1;
- }
- if (log.total_tokens !== null && log.total_tokens !== undefined) {
- totalTokens += log.total_tokens;
- } else {
- totalTokens += (log.prompt_tokens ?? 0) + (log.completion_tokens ?? 0);
- }
+const describeError = (status?: number | null, code?: string | null) => {
+ const normalized = (code ?? "").toLowerCase();
+ if (status === 401 || status === 403) {
+ return "鉴权失败";
}
+ if (status === 429 || normalized.includes("rate")) {
+ return "触发限流";
+ }
+ if (
+ normalized.includes("insufficient") ||
+ normalized.includes("quota") ||
+ normalized.includes("balance")
+ ) {
+ return "余额不足";
+ }
+ if (normalized.includes("model")) {
+ return "模型不可用";
+ }
+ return null;
+};
+
+export type UsageStatusDetail = {
+ label: string;
+ message: string | null;
+ tone: "success" | "error";
+};
+
+export const buildUsageStatusDetail = (log: UsageLog): UsageStatusDetail => {
+ if (log.status === "ok") {
+ return { label: "成功", message: null, tone: "success" };
+ }
+ const parts: string[] = [];
+ if (log.upstream_status !== null && log.upstream_status !== undefined) {
+ parts.push(String(log.upstream_status));
+ }
+ if (log.error_code) {
+ parts.push(log.error_code);
+ }
+ const hint = describeError(log.upstream_status, log.error_code ?? null);
+ if (hint) {
+ parts.push(hint);
+ }
+ const suffix = parts.length > 0 ? ` (${parts.join(" / ")})` : "";
return {
- total,
- success,
- failed,
- errorRate: total > 0 ? (failed / total) * 100 : 0,
- avgLatencyMs: latencyCount > 0 ? latencySum / latencyCount : null,
- totalTokens,
+ label: `失败${suffix}`,
+ message: truncateText(log.error_message),
+ tone: "error",
};
};
diff --git a/apps/ui/src/features/UsageView.tsx b/apps/ui/src/features/UsageView.tsx
index 7370246..6eff7f6 100644
--- a/apps/ui/src/features/UsageView.tsx
+++ b/apps/ui/src/features/UsageView.tsx
@@ -1,6 +1,10 @@
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import type { UsageLog } from "../core/types";
-import { buildPageItems, formatDateTime, summarizeUsageLogs } from "../core/utils";
+import {
+ buildPageItems,
+ buildUsageStatusDetail,
+ formatDateTime,
+} from "../core/utils";
type UsageViewProps = {
usage: UsageLog[];
@@ -42,7 +46,6 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(1);
const total = usage.length;
- const summary = useMemo(() => summarizeUsageLogs(usage), [usage]);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / pageSize)),
[total, pageSize],
@@ -86,31 +89,6 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
-
-
-
- 概述(当前展示 {summary.total} 条)
-
-
- 成功 {summary.success}
-
-
- 失败 {summary.failed}
-
-
- 错误率 {summary.total === 0 ? "-" : `${summary.errorRate.toFixed(1)}%`}
-
-
- 平均延迟{" "}
- {summary.avgLatencyMs === null
- ? "-"
- : `${summary.avgLatencyMs.toFixed(0)} ms`}
-
-
- 总 Tokens {summary.totalTokens}
-
-
-
@@ -162,7 +140,9 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
) : (
- pagedUsage.map((log) => (
+ pagedUsage.map((log) => {
+ const statusDetail = buildUsageStatusDetail(log);
+ return (
|
{formatDateTime(log.created_at)}
@@ -195,10 +175,24 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
{log.reasoning_effort ?? "-"}
|
- {log.status}
+
+ {statusDetail.label}
+
+ {statusDetail.message ? (
+
+ {statusDetail.message}
+
+ ) : null}
|
- ))
+ );
+ })
)}
diff --git a/apps/worker/migrations/0005_add_usage_error_fields.sql b/apps/worker/migrations/0005_add_usage_error_fields.sql
new file mode 100644
index 0000000..6ce4dba
--- /dev/null
+++ b/apps/worker/migrations/0005_add_usage_error_fields.sql
@@ -0,0 +1,3 @@
+ALTER TABLE usage_logs ADD COLUMN upstream_status INTEGER;
+ALTER TABLE usage_logs ADD COLUMN error_code TEXT;
+ALTER TABLE usage_logs ADD COLUMN error_message TEXT;
diff --git a/apps/worker/src/db/schema.sql b/apps/worker/src/db/schema.sql
index 6c68c95..d66699c 100644
--- a/apps/worker/src/db/schema.sql
+++ b/apps/worker/src/db/schema.sql
@@ -65,6 +65,9 @@ CREATE TABLE IF NOT EXISTS usage_logs (
stream INTEGER,
reasoning_effort TEXT,
status TEXT,
+ upstream_status INTEGER,
+ error_code TEXT,
+ error_message TEXT,
created_at TEXT NOT NULL
);
diff --git a/apps/worker/src/routes/proxy.ts b/apps/worker/src/routes/proxy.ts
index 2c33a82..15d2e0f 100644
--- a/apps/worker/src/routes/proxy.ts
+++ b/apps/worker/src/routes/proxy.ts
@@ -74,6 +74,54 @@ function scheduleDbWrite(
}
}
+type ErrorDetails = {
+ upstreamStatus: number | null;
+ errorCode: string | null;
+ errorMessage: string | null;
+};
+
+function truncateMessage(value: string | null, max = 240): string | null {
+ if (!value) {
+ return null;
+ }
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return null;
+ }
+ return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
+}
+
+async function extractErrorDetails(
+ response: Response,
+): Promise<{ errorCode: string | null; errorMessage: string | null }> {
+ const contentType = response.headers.get("content-type") ?? "";
+ if (contentType.includes("application/json")) {
+ const payload = await response.clone().json().catch(() => null);
+ if (payload && typeof payload === "object") {
+ const raw = payload as Record
;
+ const error = (raw.error ?? raw) as Record;
+ const errorCode =
+ typeof error.code === "string"
+ ? error.code
+ : typeof error.type === "string"
+ ? error.type
+ : null;
+ const errorMessage =
+ typeof error.message === "string"
+ ? error.message
+ : typeof raw.message === "string"
+ ? raw.message
+ : null;
+ return {
+ errorCode,
+ errorMessage: truncateMessage(errorMessage),
+ };
+ }
+ }
+ const text = await response.clone().text().catch(() => "");
+ return { errorCode: null, errorMessage: truncateMessage(text) };
+}
+
function channelSupportsModel(
channel: ChannelRecord,
model: string | null,
@@ -402,6 +450,7 @@ proxy.all("/*", tokenAuth, async (c) => {
let lastResponse: Response | null = null;
let lastChannel: ChannelRecord | null = null;
let lastRequestPath = targetPath;
+ let lastErrorDetails: ErrorDetails | null = null;
const start = Date.now();
let selectedChannel: ChannelRecord | null = null;
let selectedUpstreamProvider: ProviderType | null = null;
@@ -649,6 +698,7 @@ proxy.all("/*", tokenAuth, async (c) => {
selectedChannel = channel;
selectedUpstreamProvider = upstreamProvider;
selectedUpstreamModel = upstreamModel;
+ lastErrorDetails = null;
if (recordModel) {
scheduleDbWrite(
c,
@@ -662,6 +712,12 @@ proxy.all("/*", tokenAuth, async (c) => {
}
break;
}
+ const errorInfo = await extractErrorDetails(response);
+ lastErrorDetails = {
+ upstreamStatus: response.status,
+ errorCode: errorInfo.errorCode,
+ errorMessage: errorInfo.errorMessage,
+ };
if (recordModel) {
scheduleDbWrite(
c,
@@ -688,6 +744,13 @@ proxy.all("/*", tokenAuth, async (c) => {
reason: isTimeout ? "timeout" : "exception",
error: error instanceof Error ? error.message : String(error),
});
+ lastErrorDetails = {
+ upstreamStatus: null,
+ errorCode: isTimeout ? "timeout" : "exception",
+ errorMessage: truncateMessage(
+ error instanceof Error ? error.message : String(error),
+ ),
+ };
if (recordModel) {
scheduleDbWrite(
c,
@@ -732,12 +795,23 @@ proxy.all("/*", tokenAuth, async (c) => {
stream: isStream,
reasoningEffort,
status: "error",
+ upstreamStatus: lastErrorDetails?.upstreamStatus ?? null,
+ errorCode: lastErrorDetails?.errorCode ?? "upstream_unavailable",
+ errorMessage: lastErrorDetails?.errorMessage ?? "upstream_unavailable",
});
return jsonError(c, 502, "upstream_unavailable", "upstream_unavailable");
}
const channelForUsage = selectedChannel ?? lastChannel;
if (channelForUsage && lastResponse) {
+ const errorDetails =
+ lastResponse.ok || selectedChannel
+ ? null
+ : lastErrorDetails ?? {
+ upstreamStatus: lastResponse.status,
+ errorCode: null,
+ errorMessage: null,
+ };
const record = async (
usage: NormalizedUsage | null,
firstTokenLatencyMs?: number | null,
@@ -763,6 +837,13 @@ proxy.all("/*", tokenAuth, async (c) => {
stream: isStream,
reasoningEffort,
status: lastResponse.ok ? "ok" : "error",
+ upstreamStatus: lastResponse.ok
+ ? null
+ : errorDetails?.upstreamStatus ?? lastResponse.status,
+ errorCode: lastResponse.ok ? null : errorDetails?.errorCode ?? null,
+ errorMessage: lastResponse.ok
+ ? null
+ : errorDetails?.errorMessage ?? null,
});
};
const headerUsage = parseUsageFromHeaders(lastResponse.headers);
diff --git a/apps/worker/src/services/usage.ts b/apps/worker/src/services/usage.ts
index 68c1d3c..af8568a 100644
--- a/apps/worker/src/services/usage.ts
+++ b/apps/worker/src/services/usage.ts
@@ -15,6 +15,9 @@ export type UsageInput = {
stream?: boolean | number | null;
reasoningEffort?: string | number | null;
status?: string | null;
+ upstreamStatus?: number | null;
+ errorCode?: string | null;
+ errorMessage?: string | null;
};
/**
@@ -40,7 +43,7 @@ export async function recordUsage(
: String(input.reasoningEffort);
await db
.prepare(
- "INSERT INTO usage_logs (id, token_id, channel_id, model, request_path, total_tokens, prompt_tokens, completion_tokens, cost, latency_ms, first_token_latency_ms, stream, reasoning_effort, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO usage_logs (id, token_id, channel_id, model, request_path, total_tokens, prompt_tokens, completion_tokens, cost, latency_ms, first_token_latency_ms, stream, reasoning_effort, status, upstream_status, error_code, error_message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(
id,
@@ -57,6 +60,9 @@ export async function recordUsage(
streamValue,
reasoningValue,
input.status ?? "ok",
+ input.upstreamStatus ?? null,
+ input.errorCode ?? null,
+ input.errorMessage ?? null,
createdAt,
)
.run();
diff --git a/tests/ui/usage-status.test.ts b/tests/ui/usage-status.test.ts
new file mode 100644
index 0000000..d5119ad
--- /dev/null
+++ b/tests/ui/usage-status.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { buildUsageStatusDetail } from "../../apps/ui/src/core/utils";
+
+const buildLog = (partial: Partial<{ status: string; upstream_status: number | null; error_code: string | null; error_message: string | null }>) => ({
+ id: "1",
+ model: "gpt-4",
+ channel_id: "c1",
+ token_id: "t1",
+ total_tokens: 1,
+ latency_ms: 100,
+ status: partial.status ?? "ok",
+ upstream_status: partial.upstream_status ?? null,
+ error_code: partial.error_code ?? null,
+ error_message: partial.error_message ?? null,
+ created_at: "2026-03-14T00:00:00Z",
+});
+
+describe("usage status detail", () => {
+ it("renders success label", () => {
+ const detail = buildUsageStatusDetail(buildLog({ status: "ok" }) as any);
+ expect(detail.label).toBe("成功");
+ expect(detail.tone).toBe("success");
+ });
+
+ it("renders failure with status and code", () => {
+ const detail = buildUsageStatusDetail(
+ buildLog({
+ status: "error",
+ upstream_status: 403,
+ error_code: "authentication_error",
+ error_message: "Invalid API key",
+ }) as any,
+ );
+ expect(detail.label).toContain("失败");
+ expect(detail.label).toContain("403");
+ expect(detail.label).toContain("authentication_error");
+ expect(detail.message).toBe("Invalid API key");
+ });
+});
diff --git a/tests/ui/usage.test.ts b/tests/ui/usage.test.ts
deleted file mode 100644
index 656ef11..0000000
--- a/tests/ui/usage.test.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { summarizeUsageLogs } from "../../apps/ui/src/core/utils";
-
-const buildLog = (partial: Partial<{ status: string; total_tokens: number | null; prompt_tokens: number | null; completion_tokens: number | null; latency_ms: number | null }>) => ({
- id: "1",
- model: "gpt-4",
- channel_id: "c1",
- token_id: "t1",
- total_tokens: partial.total_tokens ?? null,
- prompt_tokens: partial.prompt_tokens ?? null,
- completion_tokens: partial.completion_tokens ?? null,
- latency_ms: partial.latency_ms ?? null,
- status: partial.status ?? "ok",
- created_at: "2026-03-14T00:00:00Z",
-});
-
-describe("usage summary", () => {
- it("summarizes success, failure, tokens, and latency", () => {
- const logs = [
- buildLog({ status: "ok", total_tokens: 20, latency_ms: 1000 }),
- buildLog({ status: "error", total_tokens: 10, latency_ms: 3000 }),
- buildLog({ status: "ok", total_tokens: null, prompt_tokens: 5, completion_tokens: 5, latency_ms: null }),
- ];
- const summary = summarizeUsageLogs(logs as any);
- expect(summary.total).toBe(3);
- expect(summary.success).toBe(2);
- expect(summary.failed).toBe(1);
- expect(summary.totalTokens).toBe(40);
- expect(Math.round(summary.errorRate)).toBe(33);
- expect(summary.avgLatencyMs).toBe(2000);
- });
-});