feat(usage): add detailed error status
This commit is contained in:
1 parent
9005bce339
commit
8af53f34f0
12 files changed
+219
-99
No files matched your search
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
- done-hub 站点仅使用 base_url,不再维护多地址输入
|
||||
- 站点连通测试提示令牌成功/失败统计
|
||||
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
|
||||
- 使用日志顶部展示概述(总量/成功/失败/错误率/平均延迟)
|
||||
- 使用日志状态列展示详细失败原因(状态码/错误码/摘要)
|
||||
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
|
||||
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
|
||||
- 系统设置增加定时签到开关与时间(中国时间)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- 查询结果附带渠道与令牌名称
|
||||
- usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度
|
||||
- 推理强度来自请求体 `reasoning` / `reasoning_effort` 字段
|
||||
- 前端使用日志列表计算概述(总量/成功/失败/错误率/平均延迟)
|
||||
- usage_logs 记录上游失败详情(status/code/message),用于日志状态展示
|
||||
|
||||
## 依赖关系
|
||||
- `usage_logs` 表
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+54
-31
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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) => {
|
||||
</button>
|
||||
</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="h-[420px] overflow-auto sm:h-[520px]">
|
||||
<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>
|
||||
</tr>
|
||||
) : (
|
||||
pagedUsage.map((log) => (
|
||||
pagedUsage.map((log) => {
|
||||
const statusDetail = buildUsageStatusDetail(log);
|
||||
return (
|
||||
<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">
|
||||
{formatDateTime(log.created_at)}
|
||||
@@ -195,10 +175,24 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
|
||||
{log.reasoning_effort ?? "-"}
|
||||
</td>
|
||||
<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>
|
||||
</tr>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</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;
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user