feat(usage): add detailed error status

This commit is contained in:
lsy committed 2026-03-14 18:31:03 +08:00
1 parent 9005bce339
commit 8af53f34f0
12 files changed
+219 -99

No files matched your search

+3 -3
View File
@@ -26,9 +26,9 @@
- **[proxy]**: 失败判定简化为非 2xx/超时即失败 - **[proxy]**: 失败判定简化为非 2xx/超时即失败
- 方案: [202603141749_usage-summary](archive/2026-03/202603141749_usage-summary/) - 方案: [202603141749_usage-summary](archive/2026-03/202603141749_usage-summary/)
- 文件: apps/worker/src/routes/proxy.ts - 文件: apps/worker/src/routes/proxy.ts
- **[admin-ui/usage]**: 使用日志新增概述卡片(总量/成功/失败/错误率/平均延迟) - **[usage/proxy]**: 使用日志记录上游失败详情并在日志状态列展示
- 方案: [202603141749_usage-summary](archive/2026-03/202603141749_usage-summary/) - 方案: [202603141815_usage-log-status](archive/2026-03/202603141815_usage-log-status/)
- 文件: 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 - 文件: 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]**: 移除临时调试日志(请求入口/出口与上游响应汇总) - **[worker/proxy]**: 移除临时调试日志(请求入口/出口与上游响应汇总)
- 类型: 微调(无方案包) - 类型: 微调(无方案包)
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/proxy.ts - 文件: apps/worker/src/index.ts, apps/worker/src/routes/proxy.ts
+1 -1
View File
@@ -25,7 +25,7 @@
- done-hub 站点仅使用 base_url,不再维护多地址输入 - done-hub 站点仅使用 base_url,不再维护多地址输入
- 站点连通测试提示令牌成功/失败统计 - 站点连通测试提示令牌成功/失败统计
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度 - 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
- 使用日志顶部展示概述(总量/成功/失败/错误率/平均延迟) - 使用日志状态列展示详细失败原因(状态码/错误码/摘要)
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀 - 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`) - 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
- 系统设置增加定时签到开关与时间(中国时间) - 系统设置增加定时签到开关与时间(中国时间)
+1 -1
View File
@@ -14,7 +14,7 @@
- 查询结果附带渠道与令牌名称 - 查询结果附带渠道与令牌名称
- usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度 - usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度
- 推理强度来自请求体 `reasoning` / `reasoning_effort` 字段 - 推理强度来自请求体 `reasoning` / `reasoning_effort` 字段
- 前端使用日志列表计算概述(总量/成功/失败/错误率/平均延迟) - usage_logs 记录上游失败详情(status/code/message),用于日志状态展示
## 依赖关系 ## 依赖关系
- `usage_logs` 表 - `usage_logs` 表
+3
View File
@@ -60,6 +60,9 @@ export type UsageLog = {
stream?: boolean | number | null; stream?: boolean | number | null;
reasoning_effort?: string | number | null; reasoning_effort?: string | number | null;
status: string; status: string;
upstream_status?: number | null;
error_code?: string | null;
error_message?: string | null;
created_at: string; created_at: string;
}; };
+54 -31
View File
@@ -79,40 +79,63 @@ export const buildPageItems = (current: number, total: number): PageItem[] => {
}); });
}; };
export type UsageSummary = { const truncateText = (value: string | null | undefined, max = 80) => {
total: number; if (!value) {
success: number; return null;
failed: number; }
errorRate: number; const trimmed = value.trim();
avgLatencyMs: number | null; if (!trimmed) {
totalTokens: number; return null;
}
return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
}; };
export const summarizeUsageLogs = (logs: UsageLog[]): UsageSummary => { const describeError = (status?: number | null, code?: string | null) => {
const total = logs.length; const normalized = (code ?? "").toLowerCase();
const success = logs.filter((log) => log.status === "ok").length; if (status === 401 || status === 403) {
const failed = total - success; return "鉴权失败";
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);
}
} }
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 { return {
total, label: `失败${suffix}`,
success, message: truncateText(log.error_message),
failed, tone: "error",
errorRate: total > 0 ? (failed / total) * 100 : 0,
avgLatencyMs: latencyCount > 0 ? latencySum / latencyCount : null,
totalTokens,
}; };
}; };
+24 -30
View File
@@ -1,6 +1,10 @@
import { useEffect, useMemo, useState } from "hono/jsx/dom"; import { useEffect, useMemo, useState } from "hono/jsx/dom";
import type { UsageLog } from "../core/types"; import type { UsageLog } from "../core/types";
import { buildPageItems, formatDateTime, summarizeUsageLogs } from "../core/utils"; import {
buildPageItems,
buildUsageStatusDetail,
formatDateTime,
} from "../core/utils";
type UsageViewProps = { type UsageViewProps = {
usage: UsageLog[]; usage: UsageLog[];
@@ -42,7 +46,6 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const total = usage.length; const total = usage.length;
const summary = useMemo(() => summarizeUsageLogs(usage), [usage]);
const totalPages = useMemo( const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / pageSize)), () => Math.max(1, Math.ceil(total / pageSize)),
[total, pageSize], [total, pageSize],
@@ -86,31 +89,6 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
</button> </button>
</div> </div>
</div> </div>
<div class="mt-4 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3">
<div class="flex flex-wrap items-center gap-3 text-xs text-stone-600">
<span class="font-semibold text-stone-700">
概述(当前展示 {summary.total} 条)
</span>
<span class="rounded-full bg-emerald-100 px-3 py-1 font-semibold text-emerald-700">
成功 {summary.success}
</span>
<span class="rounded-full bg-rose-100 px-3 py-1 font-semibold text-rose-700">
失败 {summary.failed}
</span>
<span class="rounded-full bg-stone-200 px-3 py-1 font-semibold text-stone-700">
错误率 {summary.total === 0 ? "-" : `${summary.errorRate.toFixed(1)}%`}
</span>
<span class="rounded-full bg-stone-200 px-3 py-1 font-semibold text-stone-700">
平均延迟{" "}
{summary.avgLatencyMs === null
? "-"
: `${summary.avgLatencyMs.toFixed(0)} ms`}
</span>
<span class="rounded-full bg-stone-200 px-3 py-1 font-semibold text-stone-700">
总 Tokens {summary.totalTokens}
</span>
</div>
</div>
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200"> <div class="mt-4 overflow-hidden rounded-xl border border-stone-200">
<div class="h-[420px] overflow-auto sm:h-[520px]"> <div class="h-[420px] overflow-auto sm:h-[520px]">
<table class="min-w-[960px] w-full border-collapse text-xs sm:text-sm"> <table class="min-w-[960px] w-full border-collapse text-xs sm:text-sm">
@@ -162,7 +140,9 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
</td> </td>
</tr> </tr>
) : ( ) : (
pagedUsage.map((log) => ( pagedUsage.map((log) => {
const statusDetail = buildUsageStatusDetail(log);
return (
<tr class="hover:bg-stone-50" key={log.id}> <tr class="hover:bg-stone-50" key={log.id}>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm"> <td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
{formatDateTime(log.created_at)} {formatDateTime(log.created_at)}
@@ -195,10 +175,24 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
{log.reasoning_effort ?? "-"} {log.reasoning_effort ?? "-"}
</td> </td>
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm"> <td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
{log.status} <div
class={
statusDetail.tone === "success"
? "font-semibold text-emerald-700"
: "font-semibold text-rose-700"
}
>
{statusDetail.label}
</div>
{statusDetail.message ? (
<div class="mt-1 text-[11px] text-stone-500">
{statusDetail.message}
</div>
) : null}
</td> </td>
</tr> </tr>
)) );
})
)} )}
</tbody> </tbody>
</table> </table>
@@ -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;
+3
View File
@@ -65,6 +65,9 @@ CREATE TABLE IF NOT EXISTS usage_logs (
stream INTEGER, stream INTEGER,
reasoning_effort TEXT, reasoning_effort TEXT,
status TEXT, status TEXT,
upstream_status INTEGER,
error_code TEXT,
error_message TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
+81
View File
@@ -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<string, unknown>;
const error = (raw.error ?? raw) as Record<string, unknown>;
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( function channelSupportsModel(
channel: ChannelRecord, channel: ChannelRecord,
model: string | null, model: string | null,
@@ -402,6 +450,7 @@ proxy.all("/*", tokenAuth, async (c) => {
let lastResponse: Response | null = null; let lastResponse: Response | null = null;
let lastChannel: ChannelRecord | null = null; let lastChannel: ChannelRecord | null = null;
let lastRequestPath = targetPath; let lastRequestPath = targetPath;
let lastErrorDetails: ErrorDetails | null = null;
const start = Date.now(); const start = Date.now();
let selectedChannel: ChannelRecord | null = null; let selectedChannel: ChannelRecord | null = null;
let selectedUpstreamProvider: ProviderType | null = null; let selectedUpstreamProvider: ProviderType | null = null;
@@ -649,6 +698,7 @@ proxy.all("/*", tokenAuth, async (c) => {
selectedChannel = channel; selectedChannel = channel;
selectedUpstreamProvider = upstreamProvider; selectedUpstreamProvider = upstreamProvider;
selectedUpstreamModel = upstreamModel; selectedUpstreamModel = upstreamModel;
lastErrorDetails = null;
if (recordModel) { if (recordModel) {
scheduleDbWrite( scheduleDbWrite(
c, c,
@@ -662,6 +712,12 @@ proxy.all("/*", tokenAuth, async (c) => {
} }
break; break;
} }
const errorInfo = await extractErrorDetails(response);
lastErrorDetails = {
upstreamStatus: response.status,
errorCode: errorInfo.errorCode,
errorMessage: errorInfo.errorMessage,
};
if (recordModel) { if (recordModel) {
scheduleDbWrite( scheduleDbWrite(
c, c,
@@ -688,6 +744,13 @@ proxy.all("/*", tokenAuth, async (c) => {
reason: isTimeout ? "timeout" : "exception", reason: isTimeout ? "timeout" : "exception",
error: error instanceof Error ? error.message : String(error), 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) { if (recordModel) {
scheduleDbWrite( scheduleDbWrite(
c, c,
@@ -732,12 +795,23 @@ proxy.all("/*", tokenAuth, async (c) => {
stream: isStream, stream: isStream,
reasoningEffort, reasoningEffort,
status: "error", status: "error",
upstreamStatus: lastErrorDetails?.upstreamStatus ?? null,
errorCode: lastErrorDetails?.errorCode ?? "upstream_unavailable",
errorMessage: lastErrorDetails?.errorMessage ?? "upstream_unavailable",
}); });
return jsonError(c, 502, "upstream_unavailable", "upstream_unavailable"); return jsonError(c, 502, "upstream_unavailable", "upstream_unavailable");
} }
const channelForUsage = selectedChannel ?? lastChannel; const channelForUsage = selectedChannel ?? lastChannel;
if (channelForUsage && lastResponse) { if (channelForUsage && lastResponse) {
const errorDetails =
lastResponse.ok || selectedChannel
? null
: lastErrorDetails ?? {
upstreamStatus: lastResponse.status,
errorCode: null,
errorMessage: null,
};
const record = async ( const record = async (
usage: NormalizedUsage | null, usage: NormalizedUsage | null,
firstTokenLatencyMs?: number | null, firstTokenLatencyMs?: number | null,
@@ -763,6 +837,13 @@ proxy.all("/*", tokenAuth, async (c) => {
stream: isStream, stream: isStream,
reasoningEffort, reasoningEffort,
status: lastResponse.ok ? "ok" : "error", 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); const headerUsage = parseUsageFromHeaders(lastResponse.headers);
+7 -1
View File
@@ -15,6 +15,9 @@ export type UsageInput = {
stream?: boolean | number | null; stream?: boolean | number | null;
reasoningEffort?: string | number | null; reasoningEffort?: string | number | null;
status?: string | null; status?: string | null;
upstreamStatus?: number | null;
errorCode?: string | null;
errorMessage?: string | null;
}; };
/** /**
@@ -40,7 +43,7 @@ export async function recordUsage(
: String(input.reasoningEffort); : String(input.reasoningEffort);
await db await db
.prepare( .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( .bind(
id, id,
@@ -57,6 +60,9 @@ export async function recordUsage(
streamValue, streamValue,
reasoningValue, reasoningValue,
input.status ?? "ok", input.status ?? "ok",
input.upstreamStatus ?? null,
input.errorCode ?? null,
input.errorMessage ?? null,
createdAt, createdAt,
) )
.run(); .run();
+39
View File
@@ -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");
});
});
-32
View File
@@ -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);
});
});