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/超时即失败
|
- **[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
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
- done-hub 站点仅使用 base_url,不再维护多地址输入
|
- done-hub 站点仅使用 base_url,不再维护多地址输入
|
||||||
- 站点连通测试提示令牌成功/失败统计
|
- 站点连通测试提示令牌成功/失败统计
|
||||||
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
|
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
|
||||||
- 使用日志顶部展示概述(总量/成功/失败/错误率/平均延迟)
|
- 使用日志状态列展示详细失败原因(状态码/错误码/摘要)
|
||||||
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
|
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
|
||||||
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
|
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
|
||||||
- 系统设置增加定时签到开关与时间(中国时间)
|
- 系统设置增加定时签到开关与时间(中国时间)
|
||||||
|
|||||||
@@ -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` 表
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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