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); - }); -});